Распаковал архив Proxmox-VPS-Panel.rar и добавил содержимое в репозиторий
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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])
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Panel — VPS на вашем Proxmox</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <Navigate to="/login" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Topbar() {
|
||||||
|
const { user, setUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="prompt">$</span> vps-panel
|
||||||
|
</div>
|
||||||
|
{user && (
|
||||||
|
<div className="topbar-right">
|
||||||
|
<Link to="/">Мои VPS</Link>
|
||||||
|
{user.role === "admin" && <Link to="/admin/templates">Шаблоны</Link>}
|
||||||
|
{user.role === "admin" && <Link to="/admin/users">Пользователи</Link>}
|
||||||
|
<span className="badge">{user.email}</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
api.logout();
|
||||||
|
setUser(null);
|
||||||
|
navigate("/login");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user, setUser, loading } = useBootstrap();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, setUser }}>
|
||||||
|
<div className="shell">
|
||||||
|
<Topbar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<Protected user={user} loading={loading}>
|
||||||
|
<Dashboard />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/instances/:id"
|
||||||
|
element={
|
||||||
|
<Protected user={user} loading={loading}>
|
||||||
|
<InstanceDetail />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/templates"
|
||||||
|
element={
|
||||||
|
<Protected user={user} loading={loading}>
|
||||||
|
<AdminTemplates />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/users"
|
||||||
|
element={
|
||||||
|
<Protected user={user} loading={loading}>
|
||||||
|
<AdminUsers />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" }),
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="status-row" style={{ marginBottom: 8 }}>
|
||||||
|
<span className={`dot ${error ? "error" : "running"}`} /> {error ? "Ошибка консоли" : status}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="error-box">
|
||||||
|
{error}. Консоль VNC зависит от версии Proxmox и настроек аутентификации — см. README, раздел
|
||||||
|
«Консоль VNC».
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="console-frame" ref={containerRef} style={{ width: "100%", height: 480 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span>🔥 {live.cpu}%</span>
|
||||||
|
<div style={{ width: 50, height: 8, background: "#333", borderRadius: 4, overflow: "hidden" }}>
|
||||||
|
<div style={{ width: `${Math.min(live.cpu, 100)}%`, height: "100%",
|
||||||
|
background: live.cpu > 80 ? "#f44336" : live.cpu > 50 ? "#ff9800" : "#4caf50",
|
||||||
|
borderRadius: 4, transition: "width 0.5s" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const memBar = live && live.mem_total > 0 ? (
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span>🧠 {fmt1(formatMem(live.mem_used / (1024 * 1024)))} / {fmt1(formatMem(live.mem_total / (1024 * 1024)))}</span>
|
||||||
|
<div style={{ width: 50, height: 8, background: "#333", borderRadius: 4, overflow: "hidden" }}>
|
||||||
|
<div style={{ width: `${fmt1(Math.min((live.mem_used / live.mem_total) * 100, 100))}%`, height: "100%",
|
||||||
|
background: (live.mem_used / live.mem_total) > 0.8 ? "#f44336" : (live.mem_used / live.mem_total) > 0.5 ? "#ff9800" : "#4caf50",
|
||||||
|
borderRadius: 4, transition: "width 0.5s" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="instance-card">
|
||||||
|
<div className="status-row">
|
||||||
|
<span className={`dot ${instance.status}`} />
|
||||||
|
{STATUS_LABEL[instance.status] || instance.status}
|
||||||
|
<span className="badge">{instance.guest_type === "vm" ? "VM" : "LXC"}</span>
|
||||||
|
{live && live.status === "running" && (
|
||||||
|
<span style={{ marginLeft: 8, fontSize: 12, color: "#aaa" }}>
|
||||||
|
⏱ {formatUptime(live.uptime)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="instance-name">
|
||||||
|
<Link to={`/instances/${instance.id}`}>{instance.name}</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live-показатели */}
|
||||||
|
{live && live.status === "running" && (
|
||||||
|
<div style={{ margin: "8px 0", display: "flex", gap: 16, flexWrap: "wrap", fontSize: 13 }}>
|
||||||
|
{cpuBar}
|
||||||
|
{memBar}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Статическая информация */}
|
||||||
|
<div className="instance-meta">
|
||||||
|
vmid {instance.vmid} · node {instance.node}
|
||||||
|
{instance.template && (
|
||||||
|
<> · 💻 {instance.template.cores} vCPU · 🧠 {instance.template.memory_mb} МБ · 💾 {instance.template.disk_gb} ГБ</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="instance-meta" style={{ marginTop: 6 }}>
|
||||||
|
🌐 <strong>{ip}</strong>
|
||||||
|
{instance.ciuser && <> · 👤 {instance.ciuser}</>}
|
||||||
|
{instance.root_password && (
|
||||||
|
<span style={{ color: "#69f0ae" }}> · 🔑 {instance.root_password}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="instance-actions">
|
||||||
|
<button className="btn btn-sm" disabled={instance.status !== "stopped"} onClick={() => onAction(instance.id, "start")}>Старт</button>
|
||||||
|
<button className="btn btn-sm" disabled={instance.status !== "running"} onClick={() => onAction(instance.id, "reboot")}>Reboot</button>
|
||||||
|
<button className="btn btn-sm" disabled={instance.status !== "running"} onClick={() => onAction(instance.id, "stop")}>Стоп</button>
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={() => onDelete(instance.id)}>Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -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 (
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="page-title">Шаблоны VPS</h1>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<h3>🔍 Найти шаблоны на Proxmox</h3>
|
||||||
|
<button className="btn btn-primary" onClick={fetchProxmox} disabled={busy}>{busy ? "Ищем…" : "Найти шаблоны"}</button>
|
||||||
|
{proxmoxTemplates && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
{proxmoxTemplates.vm_templates?.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}><h4>🖥 VM-шаблоны</h4>
|
||||||
|
<table className="data-table"><thead><tr><th>VMID</th><th>Имя</th><th>vCPU</th><th>RAM</th><th></th></tr></thead>
|
||||||
|
<tbody>{proxmoxTemplates.vm_templates.map(vm => (
|
||||||
|
<tr key={vm.vmid}><td>{vm.vmid}</td><td>{vm.name}</td><td>{vm.cores}</td><td>{vm.memory_mb} МБ</td>
|
||||||
|
<td><button className="btn btn-primary" onClick={() => importVm(vm)}>Импорт</button></td></tr>))}
|
||||||
|
</tbody></table></div>)}
|
||||||
|
{proxmoxTemplates.lxc_templates?.length > 0 && (
|
||||||
|
<div><h4>📦 LXC-шаблоны</h4>
|
||||||
|
<table className="data-table"><thead><tr><th>Имя</th><th>Хранилище</th><th>Размер</th><th></th></tr></thead>
|
||||||
|
<tbody>{proxmoxTemplates.lxc_templates.map((lxc,i) => (
|
||||||
|
<tr key={i}><td>{lxc.name}</td><td>{lxc.storage}</td><td>{lxc.size_mb} МБ</td>
|
||||||
|
<td><button className="btn btn-primary" onClick={() => importLxc(lxc)}>Импорт</button></td></tr>))}
|
||||||
|
</tbody></table></div>)}
|
||||||
|
{(!proxmoxTemplates.vm_templates?.length && !proxmoxTemplates.lxc_templates?.length) && <p>Шаблоны не найдены.</p>}
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="card" style={{ marginBottom: 32 }}>
|
||||||
|
<h3>➕ Добавить вручную</h3>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||||
|
<div className="field"><label>Название</label><input value={form.name} onChange={e => set("name", e.target.value)} required /></div>
|
||||||
|
<div className="field"><label>Тип</label><select value={form.guest_type} onChange={e => set("guest_type", e.target.value)}><option value="vm">VM</option><option value="lxc">LXC</option></select></div>
|
||||||
|
{form.guest_type === "vm" ? <div className="field"><label>VMID шаблона</label><input type="number" value={form.source_vmid} onChange={e => set("source_vmid", e.target.value)} placeholder="999" required /></div>
|
||||||
|
: <div className="field"><label>CT-шаблон (volid)</label><input value={form.source_template} onChange={e => set("source_template", e.target.value)} placeholder="local:vztmpl/..." required /></div>}
|
||||||
|
<div className="field"><label>Описание</label><input value={form.description} onChange={e => set("description", e.target.value)} /></div>
|
||||||
|
<div className="field"><label>vCPU</label><input type="number" min={1} value={form.cores} onChange={e => set("cores", e.target.value)} /></div>
|
||||||
|
<div className="field"><label>RAM, МБ</label><input type="number" min={256} step={256} value={form.memory_mb} onChange={e => set("memory_mb", e.target.value)} /></div>
|
||||||
|
<div className="field"><label>Диск, ГБ</label><input type="number" min={1} value={form.disk_gb} onChange={e => set("disk_gb", e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" disabled={busy} type="submit">Добавить шаблон</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>📋 Активные шаблоны</h3>
|
||||||
|
<table className="data-table"><thead><tr><th>Название</th><th>Тип</th><th>vCPU</th><th>RAM</th><th>Диск</th><th></th></tr></thead>
|
||||||
|
<tbody>{templates.map(t => (
|
||||||
|
<tr key={t.id}><td>{t.name}</td><td>{t.guest_type==="vm"?"VM":"LXC"}</td><td>{t.cores}</td><td>{t.memory_mb} МБ</td><td>{t.disk_gb} ГБ</td>
|
||||||
|
<td><button className="btn btn-danger" onClick={() => deleteTemplate(t.id)}>Удалить</button></td></tr>))}
|
||||||
|
</tbody></table>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="page-title">Пользователи</h1>
|
||||||
|
<p className="page-sub">Управление доступом клиентов к панели</p>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>{u.email}</td>
|
||||||
|
<td>{u.role === "admin" ? "Администратор" : "Клиент"}</td>
|
||||||
|
<td>{u.is_active ? "Активен" : "Заблокирован"}</td>
|
||||||
|
<td>
|
||||||
|
<button className="btn btn-sm" onClick={() => toggle(u.id)}>
|
||||||
|
{u.is_active ? "Заблокировать" : "Разблокировать"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = () => (<svg viewBox="0 0 64 64" width="28" height="28"><circle cx="32" cy="32" r="28" fill="#E95420"/><circle cx="32" cy="32" r="22" fill="#fff"/><circle cx="32" cy="32" r="14" fill="#E95420"/><g stroke="#fff" strokeWidth="3" fill="none"><line x1="32" y1="10" x2="32" y2="18"/><line x1="32" y1="46" x2="32" y2="54"/><line x1="10" y1="32" x2="18" y2="32"/><line x1="46" y1="32" x2="54" y2="32"/></g></svg>);
|
||||||
|
const DebianLogo = () => (<svg viewBox="0 0 64 64" width="28" height="28"><circle cx="32" cy="32" r="28" fill="#A81D33"/><path d="M32 8c-5 0-10 2-14 6l6 10c2-2 5-3 8-3 7 0 13 6 13 13 0 7-6 13-13 13-3 0-6-1-8-3l-6 10c4 4 9 6 14 6 13 0 24-11 24-24S45 8 32 8z" fill="#fff" opacity="0.9"/></svg>);
|
||||||
|
const CentOSLogo = () => (<svg viewBox="0 0 64 64" width="28" height="28"><circle cx="32" cy="32" r="28" fill="#932279"/><path d="M32 12l-12 8v16l12 8 12-8V20L32 12zm0 6l7 5v10l-7 5-7-5V23l7-5z" fill="#fff" opacity="0.9"/></svg>);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ maxWidth: 960, margin: "0 auto", padding: "24px 16px" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24, flexWrap: "wrap", gap: 12 }}>
|
||||||
|
<div><h1 style={{ color: WHITE, fontSize: 24, margin: 0 }}>Мои VPS</h1>
|
||||||
|
<p style={{ color: LIGHT, fontSize: 13, margin: "4px 0 0" }}>{total===0?"Нет активных серверов":`${running} из ${total} запущено`}</p></div>
|
||||||
|
<button onClick={() => setShowForm(!showForm)}
|
||||||
|
style={{ padding: "10px 20px", borderRadius: 10, border: "none", cursor: "pointer", background: showForm?"#c62828":"#4caf50", color:"#fff", fontSize:14, fontWeight:600 }}>
|
||||||
|
{showForm?"✕ Отмена":"+ Новый VPS"}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={onCreate} style={{ background: BG, borderRadius: 16, padding: 24, marginBottom: 24, border: `1px solid ${BORDER}` }}>
|
||||||
|
{error && <div className="error-box" style={{ marginBottom: 16 }}>{error}</div>}
|
||||||
|
<label style={{ color: LIGHT, fontSize: 12, textTransform: "uppercase", letterSpacing: 1, marginBottom: 10, display: "block" }}>Система</label>
|
||||||
|
<div style={{ display: "flex", gap: 10, marginBottom: 20, flexWrap: "wrap" }}>
|
||||||
|
{OS_OPTIONS.map(os => { const active=selectedOS===os.key; const has=filterByOS(templates,os.key).length>0; return (
|
||||||
|
<button key={os.key} type="button" disabled={!has} onClick={()=>{setSelectedOS(os.key);setSelectedPreset(null);}}
|
||||||
|
style={{ display:"flex", alignItems:"center", gap:8, padding:"8px 14px", borderRadius:8, border:active?`2px solid ${GREEN}`:`1px solid ${BORDER}`, background:active?"#1b5e20":has?BG2:"#111", cursor:has?"pointer":"not-allowed", opacity:has?1:0.4 }}>
|
||||||
|
<os.logo /><span style={{ color:WHITE, fontWeight:600, fontSize:13 }}>{os.label}</span><span style={{ color:DIM, fontSize:11 }}>{os.desc}</span></button>);})}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr", gap: 12, marginBottom: 16 }}>
|
||||||
|
<div><label style={{ color: LIGHT, fontSize: 11, display: "block", marginBottom: 4 }}>Имя сервера</label>
|
||||||
|
<input value={name} onChange={e=>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" }}/></div>
|
||||||
|
<div><label style={{ color: LIGHT, fontSize: 11, display: "block", marginBottom: 4 }}>👤 Пользователь</label>
|
||||||
|
<input value={ciuser} onChange={e=>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" }}/></div>
|
||||||
|
<div><label style={{ color: LIGHT, fontSize: 11, display: "block", marginBottom: 4 }}>🔑 Пароль</label>
|
||||||
|
<input type="password" value={cipassword} onChange={e=>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" }}/></div>
|
||||||
|
</div>
|
||||||
|
<label style={{ color: LIGHT, fontSize: 11, display: "block", marginBottom: 6 }}>📦 Тариф</label>
|
||||||
|
<div style={{ display: "flex", gap: 10, marginBottom: 16, alignItems: "stretch", flexWrap: "wrap" }}>
|
||||||
|
<select value={templateId} onChange={e=>setTemplateId(e.target.value)} style={{ flex:1, padding:"9px 12px", borderRadius:8, border:`1px solid ${BORDER}`, background:BG2, color:WHITE, fontSize:13, minWidth:200 }}>
|
||||||
|
{osT.map(t=>(<option key={t.id} value={t.id}>{t.name} · {t.cores} vCPU · {t.memory_mb} MB · {t.disk_gb} GB</option>))}</select>
|
||||||
|
{PRESETS.map(p=>{const match=matchPreset(osT,p); const avail=!!match; const active=selectedPreset===p.key; return(
|
||||||
|
<button key={p.key} type="button" disabled={!avail} onClick={()=>{setSelectedPreset(p.key);const m=matchPreset(osT,p);if(m)setTemplateId(String(m.id));}}
|
||||||
|
style={{ padding:"8px 14px", borderRadius:8, border:active?`2px solid ${GREEN}`:`1px solid ${BORDER}`, background:active?"#1b5e20":avail?BG2:"#111", cursor:avail?"pointer":"not-allowed", opacity:avail?1:0.35, textAlign:"center" }}>
|
||||||
|
<div style={{ fontWeight:700, color:WHITE, fontSize:16 }}>{p.label}</div><div style={{ fontSize:10, color:avail?GREEN:DIM }}>{avail?"✅":"—"}</div></button>);})}
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={busy||!templateId||!name} style={{ width:"100%", padding:"12px", borderRadius:10, border:"none", background:busy?"#555":"#4caf50", color:"#fff", fontSize:15, fontWeight:700, cursor:busy?"wait":"pointer" }}>
|
||||||
|
{busy?"⏳ Создаём...":"🚀 Развернуть VPS"}</button>
|
||||||
|
</form>)}
|
||||||
|
|
||||||
|
{loading ? <div style={{ textAlign:"center", padding:40, color:DIM }}>Загрузка...</div>
|
||||||
|
: instances.length===0 ? <div style={{ textAlign:"center", padding:60, color:DIM }}><div style={{ fontSize:56, marginBottom:16 }}>☁️</div><div style={{ fontSize:16, color:LIGHT }}>Нет активных VPS</div><div style={{ fontSize:13, marginTop:4 }}>Нажмите «+ Новый VPS» чтобы создать</div></div>
|
||||||
|
: <div style={{ display:"grid", gap:14 }}>{instances.map(inst=>(<InstanceCard key={inst.id} instance={inst} onAction={onAction} onDelete={onDelete} />))}</div>}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -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 <div className="container">Загрузка…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container">
|
||||||
|
<h1 className="page-title">{instance.name}</h1>
|
||||||
|
<p className="page-sub">
|
||||||
|
vmid {instance.vmid} · node {instance.node} · {instance.guest_type === "vm" ? "VM (QEMU)" : "LXC"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="status-row" style={{ marginBottom: 16 }}>
|
||||||
|
<span className={`dot ${instance.status}`} /> {instance.status}
|
||||||
|
</div>
|
||||||
|
<div className="instance-actions">
|
||||||
|
<button className="btn" disabled={busy || instance.status !== "stopped"} onClick={() => doAction("start")}>
|
||||||
|
Старт
|
||||||
|
</button>
|
||||||
|
<button className="btn" disabled={busy || instance.status !== "running"} onClick={() => doAction("reboot")}>
|
||||||
|
Перезагрузить
|
||||||
|
</button>
|
||||||
|
<button className="btn" disabled={busy || instance.status !== "running"} onClick={() => doAction("shutdown")}>
|
||||||
|
Выключить
|
||||||
|
</button>
|
||||||
|
<button className="btn" disabled={busy || instance.status !== "running"} onClick={() => doAction("stop")}>
|
||||||
|
Стоп (force)
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={() => setShowConsole((v) => !v)}>
|
||||||
|
{showConsole ? "Скрыть консоль" : "Открыть консоль"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger" disabled={busy} onClick={doDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showConsole && (
|
||||||
|
<div className="card">
|
||||||
|
<ConsoleViewer instanceId={instance.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="auth-wrap">
|
||||||
|
<h1 className="page-title">Вход</h1>
|
||||||
|
<p className="page-sub">Личный кабинет управления VPS</p>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
<form onSubmit={onSubmit} className="card">
|
||||||
|
<div className="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={busy} style={{ width: "100%" }}>
|
||||||
|
{busy ? "Входим…" : "Войти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="page-sub" style={{ marginTop: 16 }}>
|
||||||
|
Нет аккаунта? <Link to="/register">Зарегистрироваться</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="auth-wrap">
|
||||||
|
<h1 className="page-title">Регистрация</h1>
|
||||||
|
<p className="page-sub">Первый зарегистрированный пользователь получает права администратора</p>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
{done ? (
|
||||||
|
<div className="card">Готово, переходим на страницу входа…</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onSubmit} className="card">
|
||||||
|
<div className="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
type="password"
|
||||||
|
minLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={busy} style={{ width: "100%" }}>
|
||||||
|
{busy ? "Создаём…" : "Создать аккаунт"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<p className="page-sub" style={{ marginTop: 16 }}>
|
||||||
|
Уже есть аккаунт? <Link to="/login">Войти</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Binary file not shown.
Reference in New Issue
Block a user