fix: исправления и улучшения от 30.04.2026
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
- Обновлён backend/Dockerfile и frontend/Dockerfile - Исправлен async_ops.py (новые асинхронные операции) - Обновлён config.py - Исправлены compose.yml и compose.prod.yml - Обновлён deploy.sh - Переименованы nginx-конфиги (serverpanel → pvemanager) - Улучшены InstanceActionDialogs, InstanceDetailPage, InstancesPage - Обновлён AppLayout.tsx - Исправлен init.sql - Обновлён frontend/nginx.conf и package.json
This commit is contained in:
@@ -1,66 +1,61 @@
|
|||||||
# Multi-stage build для минимального размера образа
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM python:3.12-alpine AS builder
|
# Multi-stage build на python:3.12-slim (glibc) — wheels ставятся бинарно,
|
||||||
|
# никакого rust/cargo не требуется → сборка в разы быстрее.
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
# Set environment variables
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build dependencies
|
# Минимальные build-зависимости (на случай если какой-то пакет придётся компилировать)
|
||||||
RUN apk add --no-cache --virtual .build-deps \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
build-essential \
|
||||||
musl-dev \
|
libpq-dev \
|
||||||
postgresql-dev \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
libffi-dev \
|
|
||||||
openssl-dev \
|
|
||||||
cargo \
|
|
||||||
rust
|
|
||||||
|
|
||||||
# Copy and install Python dependencies
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --user --no-warn-script-location -r requirements.txt
|
|
||||||
|
|
||||||
# Final stage - минимальный образ
|
# BuildKit-кеш для pip — повторные сборки почти мгновенные
|
||||||
FROM python:3.12-alpine
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
pip install --user --no-warn-script-location -r requirements.txt
|
||||||
|
|
||||||
|
# ---------- Финальный образ ----------
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONPATH=/app \
|
PYTHONPATH=/app \
|
||||||
PATH=/root/.local/bin:$PATH
|
PATH=/root/.local/bin:$PATH \
|
||||||
|
TZ=Asia/Tashkent
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install only runtime dependencies
|
# Только runtime-зависимости. docker.io даёт CLI `docker` для self-update через сокет.
|
||||||
RUN apk add --no-cache \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq \
|
libpq5 \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
curl \
|
curl \
|
||||||
git \
|
git \
|
||||||
docker-cli \
|
docker.io \
|
||||||
docker-compose \
|
|
||||||
libstdc++ \
|
|
||||||
tzdata \
|
tzdata \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& mkdir -p /root/.ssh /app/logs \
|
&& mkdir -p /root/.ssh /app/logs \
|
||||||
&& cp /usr/share/zoneinfo/Asia/Tashkent /etc/localtime \
|
&& ln -fs /usr/share/zoneinfo/Asia/Tashkent /etc/localtime \
|
||||||
&& echo "Asia/Tashkent" > /etc/timezone \
|
&& echo "Asia/Tashkent" > /etc/timezone \
|
||||||
&& git config --global --add safe.directory /project
|
&& git config --global --add safe.directory /project
|
||||||
|
|
||||||
# Copy Python dependencies from builder
|
|
||||||
COPY --from=builder /root/.local /root/.local
|
COPY --from=builder /root/.local /root/.local
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
COPY init_admin.py .
|
COPY init_admin.py .
|
||||||
|
|
||||||
# Health check с быстрой проверкой
|
HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=5 \
|
||||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=2 \
|
CMD curl -fs http://localhost:8000/health || exit 1
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Оптимизированный запуск с preload для быстрого старта
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--limit-concurrency", "100", "--timeout-keep-alive", "30"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--limit-concurrency", "100", "--timeout-keep-alive", "30"]
|
||||||
|
|||||||
@@ -162,6 +162,67 @@ def _do_reinstall_sync(task_id: int, server_id: int, vmid: int, node: str,
|
|||||||
except Exception as _ce:
|
except Exception as _ce:
|
||||||
logger.warning(f"[REINSTALL #{task_id}] re-apply config failed: {_ce}")
|
logger.warning(f"[REINSTALL #{task_id}] re-apply config failed: {_ce}")
|
||||||
|
|
||||||
|
# 5) Restore disk size
|
||||||
|
disk_size_bytes = cached.disk_size
|
||||||
|
if disk_size_bytes and disk_size_bytes > 0:
|
||||||
|
_update_deploy_task(task_id, 'running', 'Восстановление размера диска...', 92, vmid=vmid, node=node)
|
||||||
|
try:
|
||||||
|
disk_size_gb = disk_size_bytes // (1024 ** 3)
|
||||||
|
if is_lxc:
|
||||||
|
lxc_cfg = client.get_container_config(node, vmid)
|
||||||
|
current_size_gb = 0
|
||||||
|
if lxc_cfg and 'rootfs' in lxc_cfg:
|
||||||
|
rootfs_val = lxc_cfg['rootfs']
|
||||||
|
if 'size=' in rootfs_val:
|
||||||
|
size_part = rootfs_val.split('size=')[1].split(',')[0]
|
||||||
|
if size_part.endswith('G'):
|
||||||
|
current_size_gb = int(size_part[:-1])
|
||||||
|
elif size_part.endswith('M'):
|
||||||
|
current_size_gb = int(size_part[:-1]) // 1024
|
||||||
|
if disk_size_gb > current_size_gb:
|
||||||
|
client.resize_container_disk(node, vmid, 'rootfs', f'{disk_size_gb}G')
|
||||||
|
logger.info(f"[REINSTALL #{task_id}] LXC {vmid} rootfs resized from {current_size_gb}G to {disk_size_gb}G")
|
||||||
|
else:
|
||||||
|
logger.info(f"[REINSTALL #{task_id}] LXC {vmid} rootfs already {current_size_gb}G >= {disk_size_gb}G, skip resize")
|
||||||
|
else:
|
||||||
|
vm_cfg = client.get_vm_config(node, vmid)
|
||||||
|
disk_key = None
|
||||||
|
current_size_gb = 0
|
||||||
|
if vm_cfg:
|
||||||
|
for key in ['scsi0', 'virtio0', 'sata0', 'scsi1', 'virtio1']:
|
||||||
|
if key in vm_cfg:
|
||||||
|
disk_val = vm_cfg[key]
|
||||||
|
if 'cdrom' in disk_val.lower() or 'cloudinit' in disk_val.lower() or 'media=cdrom' in disk_val.lower():
|
||||||
|
continue
|
||||||
|
disk_key = key
|
||||||
|
if 'size=' in disk_val:
|
||||||
|
size_part = disk_val.split('size=')[1].split(',')[0]
|
||||||
|
if size_part.endswith('G'):
|
||||||
|
current_size_gb = int(size_part[:-1])
|
||||||
|
elif size_part.endswith('M'):
|
||||||
|
current_size_gb = int(size_part[:-1]) // 1024
|
||||||
|
break
|
||||||
|
if disk_key and disk_size_gb > current_size_gb:
|
||||||
|
client.resize_vm_disk(node, vmid, disk_key, f'{disk_size_gb}G')
|
||||||
|
logger.info(f"[REINSTALL #{task_id}] VM {vmid} {disk_key} resized from {current_size_gb}G to {disk_size_gb}G")
|
||||||
|
elif disk_key:
|
||||||
|
logger.info(f"[REINSTALL #{task_id}] VM {vmid} {disk_key} already {current_size_gb}G >= {disk_size_gb}G, skip resize")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[REINSTALL #{task_id}] VM {vmid} no main disk found for resize")
|
||||||
|
except Exception as _re:
|
||||||
|
logger.warning(f"[REINSTALL #{task_id}] disk resize failed: {_re}")
|
||||||
|
|
||||||
|
# 6) Start VM/LXC
|
||||||
|
_update_deploy_task(task_id, 'running', 'Запуск инстанса...', 96, vmid=vmid, node=node)
|
||||||
|
try:
|
||||||
|
if is_lxc:
|
||||||
|
client.start_container(node, vmid)
|
||||||
|
else:
|
||||||
|
client.start_vm(node, vmid)
|
||||||
|
logger.info(f"[REINSTALL #{task_id}] vmid={vmid} started after reinstall")
|
||||||
|
except Exception as _se:
|
||||||
|
logger.warning(f"[REINSTALL #{task_id}] start after reinstall failed: {_se}")
|
||||||
|
|
||||||
LoggingService.log_proxmox_action(
|
LoggingService.log_proxmox_action(
|
||||||
db=db, action='reinstall',
|
db=db, action='reinstall',
|
||||||
resource_type='lxc' if is_lxc else 'vm', resource_id=vmid,
|
resource_type='lxc' if is_lxc else 'vm', resource_id=vmid,
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ class Settings(BaseSettings):
|
|||||||
# Database settings
|
# Database settings
|
||||||
DB_HOST: str = Field(default="localhost", env="DB_HOST")
|
DB_HOST: str = Field(default="localhost", env="DB_HOST")
|
||||||
DB_PORT: int = Field(default=5432, env="DB_PORT")
|
DB_PORT: int = Field(default=5432, env="DB_PORT")
|
||||||
DB_USER: str = Field(default="serverpanel", env="DB_USER")
|
DB_USER: str = Field(default="pvemanager", env="DB_USER")
|
||||||
DB_PASSWORD: str = Field(default="serverpanel", env="DB_PASSWORD")
|
DB_PASSWORD: str = Field(default="pvemanager", env="DB_PASSWORD")
|
||||||
DB_NAME: str = Field(default="serverpanel", env="DB_NAME")
|
DB_NAME: str = Field(default="pvemanager", env="DB_NAME")
|
||||||
DATABASE_URL: Optional[str] = Field(default=None, env="DATABASE_URL")
|
DATABASE_URL: Optional[str] = Field(default=None, env="DATABASE_URL")
|
||||||
|
|
||||||
# Security settings
|
# Security settings
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: serverpanel-nginx
|
container_name: pvemanager-nginx
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- serverpanel-network
|
- pvemanager-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "nginx", "-t"]
|
test: ["CMD", "nginx", "-t"]
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
image: certbot/certbot:latest
|
image: certbot/certbot:latest
|
||||||
container_name: serverpanel-certbot
|
container_name: pvemanager-certbot
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/certbot/conf:/etc/letsencrypt
|
- ./nginx/certbot/conf:/etc/letsencrypt
|
||||||
- ./nginx/certbot/www:/var/www/certbot
|
- ./nginx/certbot/www:/var/www/certbot
|
||||||
@@ -45,7 +45,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- nginx
|
- nginx
|
||||||
networks:
|
networks:
|
||||||
- serverpanel-network
|
- pvemanager-network
|
||||||
restart: "no"
|
restart: "no"
|
||||||
profiles:
|
profiles:
|
||||||
- ssl
|
- ssl
|
||||||
|
|||||||
38
compose.yml
38
compose.yml
@@ -1,13 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: serverpanel-db
|
container_name: pvemanager-db
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: serverpanel
|
POSTGRES_DB: pvemanager
|
||||||
POSTGRES_USER: serverpanel
|
POSTGRES_USER: pvemanager
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-serverpanel_secure_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pvemanager_secure_password}
|
||||||
TZ: ${TZ:-Asia/Tashkent}
|
TZ: ${TZ:-Asia/Tashkent}
|
||||||
# Оптимизация для быстрого старта
|
# Оптимизация для быстрого старта
|
||||||
POSTGRES_INITDB_ARGS: "--no-sync"
|
POSTGRES_INITDB_ARGS: "--no-sync"
|
||||||
@@ -16,15 +16,15 @@ services:
|
|||||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U serverpanel -d serverpanel"]
|
test: ["CMD-SHELL", "pg_isready -U pvemanager -d pvemanager"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
networks:
|
networks:
|
||||||
- serverpanel-network
|
- pvemanager-network
|
||||||
# Оптимизация производительности БД
|
# Оптимизация производительности БД (>- склеит в одну строку через пробел)
|
||||||
command: |
|
command: >-
|
||||||
postgres
|
postgres
|
||||||
-c shared_buffers=256MB
|
-c shared_buffers=256MB
|
||||||
-c max_connections=200
|
-c max_connections=200
|
||||||
@@ -39,9 +39,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: server-panel-app:latest
|
image: pvemanager-app:latest
|
||||||
pull_policy: build
|
pull_policy: build
|
||||||
container_name: serverpanel-app
|
container_name: pvemanager-app
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
env_file:
|
env_file:
|
||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://serverpanel:${POSTGRES_PASSWORD:-serverpanel_secure_password}@db:5432/serverpanel
|
- DATABASE_URL=postgresql://pvemanager:${POSTGRES_PASSWORD:-pvemanager_secure_password}@db:5432/pvemanager
|
||||||
- PYTHONPATH=/app
|
- PYTHONPATH=/app
|
||||||
- TZ=${TZ:-Asia/Tashkent}
|
- TZ=${TZ:-Asia/Tashkent}
|
||||||
- PROJECT_ROOT=/project
|
- PROJECT_ROOT=/project
|
||||||
@@ -72,12 +72,12 @@ services:
|
|||||||
- UVICORN_WORKERS=1
|
- UVICORN_WORKERS=1
|
||||||
- UVICORN_TIMEOUT_KEEP_ALIVE=30
|
- UVICORN_TIMEOUT_KEEP_ALIVE=30
|
||||||
networks:
|
networks:
|
||||||
- serverpanel-network
|
- pvemanager-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-fs", "http://localhost:8000/health"]
|
||||||
interval: 15s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 2
|
retries: 5
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
# Ограничение ресурсов для стабильности
|
# Ограничение ресурсов для стабильности
|
||||||
deploy:
|
deploy:
|
||||||
@@ -93,9 +93,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: server-panel-frontend:latest
|
image: pvemanager-frontend:latest
|
||||||
pull_policy: build
|
pull_policy: build
|
||||||
container_name: serverpanel-frontend
|
container_name: pvemanager-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -103,7 +103,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- serverpanel-network
|
- pvemanager-network
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -115,5 +115,5 @@ volumes:
|
|||||||
logs:
|
logs:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
serverpanel-network:
|
pvemanager-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
57
deploy.sh
57
deploy.sh
@@ -253,7 +253,7 @@ create_env_file() {
|
|||||||
cat > init.sql << 'EOF'
|
cat > init.sql << 'EOF'
|
||||||
-- PVEmanager Database Initialization
|
-- PVEmanager Database Initialization
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
GRANT ALL PRIVILEGES ON DATABASE serverpanel TO serverpanel;
|
GRANT ALL PRIVILEGES ON DATABASE pvemanager TO pvemanager;
|
||||||
EOF
|
EOF
|
||||||
print_success "init.sql file created"
|
print_success "init.sql file created"
|
||||||
fi
|
fi
|
||||||
@@ -264,7 +264,7 @@ EOF
|
|||||||
print_info "Creating default .env.example..."
|
print_info "Creating default .env.example..."
|
||||||
cat > .env.example << 'EOF'
|
cat > .env.example << 'EOF'
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
POSTGRES_PASSWORD=serverpanel_secure_password
|
POSTGRES_PASSWORD=pvemanager_secure_password
|
||||||
|
|
||||||
# Timezone
|
# Timezone
|
||||||
TZ=Asia/Tashkent
|
TZ=Asia/Tashkent
|
||||||
@@ -285,10 +285,10 @@ LOG_LEVEL=INFO
|
|||||||
# Database
|
# Database
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_USER=serverpanel
|
DB_USER=pvemanager
|
||||||
DB_PASSWORD=serverpanel_secure_password
|
DB_PASSWORD=pvemanager_secure_password
|
||||||
DB_NAME=serverpanel
|
DB_NAME=pvemanager
|
||||||
DATABASE_URL=postgresql://serverpanel:serverpanel_secure_password@db:5432/serverpanel
|
DATABASE_URL=postgresql://pvemanager:pvemanager_secure_password@db:5432/pvemanager
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
SECRET_KEY=your-very-secure-secret-key-change-this-in-production-minimum-32-chars
|
SECRET_KEY=your-very-secure-secret-key-change-this-in-production-minimum-32-chars
|
||||||
@@ -312,7 +312,7 @@ EOF
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Replace default password with random one
|
# Replace default password with random one
|
||||||
sed -i "s/serverpanel_secure_password/${RANDOM_DB_PASSWORD}/g" .env
|
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" .env
|
||||||
|
|
||||||
print_success ".env file created"
|
print_success ".env file created"
|
||||||
else
|
else
|
||||||
@@ -327,7 +327,7 @@ EOF
|
|||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
|
|
||||||
# Replace passwords and keys
|
# Replace passwords and keys
|
||||||
sed -i "s/serverpanel_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env
|
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env
|
||||||
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env
|
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env
|
||||||
|
|
||||||
print_success "backend/.env file created"
|
print_success "backend/.env file created"
|
||||||
@@ -351,16 +351,16 @@ setup_nginx_config() {
|
|||||||
|
|
||||||
if [ "$use_ssl" = true ]; then
|
if [ "$use_ssl" = true ]; then
|
||||||
# Copy SSL template
|
# Copy SSL template
|
||||||
cp nginx/conf.d/serverpanel.conf.template nginx/conf.d/serverpanel.conf
|
cp nginx/conf.d/pvemanager.conf.template nginx/conf.d/pvemanager.conf
|
||||||
print_info "Using SSL configuration"
|
print_info "Using SSL configuration"
|
||||||
else
|
else
|
||||||
# Copy non-SSL template
|
# Copy non-SSL template
|
||||||
cp nginx/conf.d/serverpanel-nossl.conf.template nginx/conf.d/serverpanel.conf
|
cp nginx/conf.d/pvemanager-nossl.conf.template nginx/conf.d/pvemanager.conf
|
||||||
print_info "Using non-SSL configuration"
|
print_info "Using non-SSL configuration"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Replace domain name
|
# Replace domain name
|
||||||
sed -i "s/DOMAIN_NAME/${domain}/g" nginx/conf.d/serverpanel.conf
|
sed -i "s/DOMAIN_NAME/${domain}/g" nginx/conf.d/pvemanager.conf
|
||||||
|
|
||||||
print_success "NGINX configuration created for domain: ${domain}"
|
print_success "NGINX configuration created for domain: ${domain}"
|
||||||
}
|
}
|
||||||
@@ -431,9 +431,10 @@ deploy_with_nginx() {
|
|||||||
docker compose -f compose.yml -f compose.prod.yml down --remove-orphans 2>/dev/null || true
|
docker compose -f compose.yml -f compose.prod.yml down --remove-orphans 2>/dev/null || true
|
||||||
docker network prune -f 2>/dev/null || true
|
docker network prune -f 2>/dev/null || true
|
||||||
|
|
||||||
# Build images locally first
|
# Build images locally first (parallel + pull base images)
|
||||||
print_info "Building Docker images..."
|
print_info "Building Docker images (parallel)..."
|
||||||
docker compose -f compose.yml build
|
DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
|
||||||
|
docker compose -f compose.yml build --parallel --pull
|
||||||
|
|
||||||
if [ "$use_ssl" = true ]; then
|
if [ "$use_ssl" = true ]; then
|
||||||
# Setup HTTP config first for certificate challenge
|
# Setup HTTP config first for certificate challenge
|
||||||
@@ -494,11 +495,11 @@ deploy_with_nginx() {
|
|||||||
print_info "Starting services without SSL..."
|
print_info "Starting services without SSL..."
|
||||||
|
|
||||||
# Start in correct order
|
# Start in correct order
|
||||||
docker compose -f compose.yml -f compose.prod.yml up -d --build db
|
docker compose -f compose.yml -f compose.prod.yml up -d db
|
||||||
print_info "Waiting for database..."
|
print_info "Waiting for database..."
|
||||||
sleep 10
|
sleep 10
|
||||||
|
|
||||||
docker compose -f compose.yml -f compose.prod.yml up -d --build app
|
docker compose -f compose.yml -f compose.prod.yml up -d app
|
||||||
print_info "Waiting for application..."
|
print_info "Waiting for application..."
|
||||||
sleep 10
|
sleep 10
|
||||||
|
|
||||||
@@ -523,9 +524,11 @@ deploy_standalone() {
|
|||||||
docker compose -f compose.yml down --remove-orphans 2>/dev/null || true
|
docker compose -f compose.yml down --remove-orphans 2>/dev/null || true
|
||||||
docker network prune -f 2>/dev/null || true
|
docker network prune -f 2>/dev/null || true
|
||||||
|
|
||||||
# Build and start containers
|
# Build and start containers (parallel build, BuildKit)
|
||||||
print_info "Building and starting containers..."
|
print_info "Building and starting containers..."
|
||||||
docker compose -f compose.yml up -d --build
|
DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
|
||||||
|
docker compose -f compose.yml build --parallel --pull
|
||||||
|
docker compose -f compose.yml up -d
|
||||||
|
|
||||||
print_success "Standalone deployment completed"
|
print_success "Standalone deployment completed"
|
||||||
}
|
}
|
||||||
@@ -657,7 +660,7 @@ quick_deploy_standalone() {
|
|||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
if [ -f .env.example ]; then
|
if [ -f .env.example ]; then
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
sed -i "s/serverpanel_secure_password/${RANDOM_DB_PASSWORD}/g" .env 2>/dev/null || true
|
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" .env 2>/dev/null || true
|
||||||
else
|
else
|
||||||
create_default_env "${RANDOM_DB_PASSWORD}"
|
create_default_env "${RANDOM_DB_PASSWORD}"
|
||||||
fi
|
fi
|
||||||
@@ -666,7 +669,7 @@ quick_deploy_standalone() {
|
|||||||
if [ ! -f backend/.env ]; then
|
if [ ! -f backend/.env ]; then
|
||||||
if [ -f backend/.env.example ]; then
|
if [ -f backend/.env.example ]; then
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
sed -i "s/serverpanel_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env 2>/dev/null || true
|
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env 2>/dev/null || true
|
||||||
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env 2>/dev/null || true
|
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env 2>/dev/null || true
|
||||||
else
|
else
|
||||||
create_default_backend_env "${RANDOM_DB_PASSWORD}" "${RANDOM_SECRET_KEY}"
|
create_default_backend_env "${RANDOM_DB_PASSWORD}" "${RANDOM_SECRET_KEY}"
|
||||||
@@ -703,7 +706,7 @@ quick_deploy_nginx() {
|
|||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
if [ -f .env.example ]; then
|
if [ -f .env.example ]; then
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
sed -i "s/serverpanel_secure_password/${RANDOM_DB_PASSWORD}/g" .env 2>/dev/null || true
|
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" .env 2>/dev/null || true
|
||||||
else
|
else
|
||||||
create_default_env "${RANDOM_DB_PASSWORD}"
|
create_default_env "${RANDOM_DB_PASSWORD}"
|
||||||
fi
|
fi
|
||||||
@@ -712,7 +715,7 @@ quick_deploy_nginx() {
|
|||||||
if [ ! -f backend/.env ]; then
|
if [ ! -f backend/.env ]; then
|
||||||
if [ -f backend/.env.example ]; then
|
if [ -f backend/.env.example ]; then
|
||||||
cp backend/.env.example backend/.env
|
cp backend/.env.example backend/.env
|
||||||
sed -i "s/serverpanel_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env 2>/dev/null || true
|
sed -i "s/pvemanager_secure_password/${RANDOM_DB_PASSWORD}/g" backend/.env 2>/dev/null || true
|
||||||
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env 2>/dev/null || true
|
sed -i "s/your-very-secure-secret-key-change-this-in-production-minimum-32-chars/${RANDOM_SECRET_KEY}/g" backend/.env 2>/dev/null || true
|
||||||
else
|
else
|
||||||
create_default_backend_env "${RANDOM_DB_PASSWORD}" "${RANDOM_SECRET_KEY}"
|
create_default_backend_env "${RANDOM_DB_PASSWORD}" "${RANDOM_SECRET_KEY}"
|
||||||
@@ -730,7 +733,7 @@ quick_deploy_nginx() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create_default_env() {
|
create_default_env() {
|
||||||
local db_password="${1:-serverpanel_secure_password}"
|
local db_password="${1:-pvemanager_secure_password}"
|
||||||
cat > .env << EOF
|
cat > .env << EOF
|
||||||
POSTGRES_PASSWORD=${db_password}
|
POSTGRES_PASSWORD=${db_password}
|
||||||
TZ=Asia/Tashkent
|
TZ=Asia/Tashkent
|
||||||
@@ -739,7 +742,7 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
create_default_backend_env() {
|
create_default_backend_env() {
|
||||||
local db_password="${1:-serverpanel_secure_password}"
|
local db_password="${1:-pvemanager_secure_password}"
|
||||||
local secret_key="${2:-your-very-secure-secret-key-change-this-in-production-minimum-32-chars}"
|
local secret_key="${2:-your-very-secure-secret-key-change-this-in-production-minimum-32-chars}"
|
||||||
cat > backend/.env << EOF
|
cat > backend/.env << EOF
|
||||||
PANEL_NAME=PVEmanager
|
PANEL_NAME=PVEmanager
|
||||||
@@ -747,10 +750,10 @@ DEBUG=false
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_USER=serverpanel
|
DB_USER=pvemanager
|
||||||
DB_PASSWORD=${db_password}
|
DB_PASSWORD=${db_password}
|
||||||
DB_NAME=serverpanel
|
DB_NAME=pvemanager
|
||||||
DATABASE_URL=postgresql://serverpanel:${db_password}@db:5432/serverpanel
|
DATABASE_URL=postgresql://pvemanager:${db_password}@db:5432/pvemanager
|
||||||
SECRET_KEY=${secret_key}
|
SECRET_KEY=${secret_key}
|
||||||
ALGORITHM=HS256
|
ALGORITHM=HS256
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
|||||||
13
frontend/.dockerignore
Normal file
13
frontend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.md
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
|
||||||
|
# BuildKit-кеш npm — повторные сборки переиспользуют скачанные пакеты
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --prefer-offline --no-audit --no-fund
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Settings API proxy
|
# Settings API proxy
|
||||||
location /settings/ {
|
location /settings/api/ {
|
||||||
proxy_pass http://app:8000/settings/;
|
proxy_pass http://app:8000/settings/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -40,8 +40,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Templates API proxy
|
# Templates API proxy
|
||||||
location /templates/ {
|
location /templates/api/ {
|
||||||
proxy_pass http://app:8000/templates/;
|
proxy_pass http://app:8000/templates/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -50,8 +50,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# IPAM API proxy
|
# IPAM API proxy
|
||||||
location /ipam/ {
|
location /ipam/api/ {
|
||||||
proxy_pass http://app:8000/ipam/;
|
proxy_pass http://app:8000/ipam/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -60,8 +60,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Logs API proxy
|
# Logs API proxy
|
||||||
location /logs/ {
|
location /logs/api/ {
|
||||||
proxy_pass http://app:8000/logs/;
|
proxy_pass http://app:8000/logs/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -70,8 +70,8 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Admin API proxy
|
# Admin API proxy
|
||||||
location /admin/ {
|
location /admin/api/ {
|
||||||
proxy_pass http://app:8000/admin/;
|
proxy_pass http://app:8000/admin/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "vite build",
|
||||||
|
"build:check": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon } from 'lucide-react';
|
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon, Play, Square, RotateCcw, Power, AlertTriangle } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||||
|
|
||||||
|
export type PowerAction = 'start' | 'stop' | 'restart' | 'shutdown';
|
||||||
|
|
||||||
export type InstanceAction =
|
export type InstanceAction =
|
||||||
| 'clone'
|
| 'clone'
|
||||||
| 'reinstall'
|
| 'reinstall'
|
||||||
@@ -536,3 +538,105 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Power Confirm ====================
|
||||||
|
|
||||||
|
type ActionConfig = {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
btnLabel: string;
|
||||||
|
btnVariant: 'default' | 'outline' | 'destructive';
|
||||||
|
btnClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const POWER_ACTION_CONFIG: Record<PowerAction, ActionConfig> = {
|
||||||
|
start: {
|
||||||
|
icon: <Play className="h-4 w-4 text-green-500" />,
|
||||||
|
title: 'Запустить',
|
||||||
|
description: 'Запустить инстанс',
|
||||||
|
btnLabel: 'Запустить',
|
||||||
|
btnVariant: 'default',
|
||||||
|
btnClass: 'bg-green-600 hover:bg-green-700 text-white border-0',
|
||||||
|
},
|
||||||
|
restart: {
|
||||||
|
icon: <RotateCcw className="h-4 w-4 text-amber-500" />,
|
||||||
|
title: 'Перезапустить',
|
||||||
|
description: 'Перезапустить инстанс',
|
||||||
|
btnLabel: 'Перезапустить',
|
||||||
|
btnVariant: 'outline',
|
||||||
|
btnClass: 'border-amber-500 text-amber-500 hover:bg-amber-500/10',
|
||||||
|
},
|
||||||
|
shutdown: {
|
||||||
|
icon: <Power className="h-4 w-4 text-orange-500" />,
|
||||||
|
title: 'Выключить',
|
||||||
|
description: 'Мягко выключить инстанс (ACPI shutdown)',
|
||||||
|
btnLabel: 'Выключить',
|
||||||
|
btnVariant: 'outline',
|
||||||
|
btnClass: 'border-orange-500 text-orange-500 hover:bg-orange-500/10',
|
||||||
|
},
|
||||||
|
stop: {
|
||||||
|
icon: <Square className="h-4 w-4" />,
|
||||||
|
title: 'Остановить',
|
||||||
|
description: 'Принудительно остановить инстанс. Несохранённые данные могут быть утеряны',
|
||||||
|
btnLabel: 'Остановить',
|
||||||
|
btnVariant: 'destructive',
|
||||||
|
btnClass: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PowerConfirmDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
action,
|
||||||
|
vmName,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
action: PowerAction | null;
|
||||||
|
vmName?: string;
|
||||||
|
isPending?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!action) return null;
|
||||||
|
const cfg = POWER_ACTION_CONFIG[action];
|
||||||
|
const displayName = vmName ? `«${vmName}»` : 'выбранный инстанс';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{cfg.icon}
|
||||||
|
{cfg.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{cfg.description} {displayName}?
|
||||||
|
{action === 'stop' && (
|
||||||
|
<span className="mt-2 flex items-center gap-1.5 text-destructive">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Это аналог «выдернуть кабель из розетки».
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={cfg.btnVariant}
|
||||||
|
className={cfg.btnClass || undefined}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{cfg.btnLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { StatusDot } from '@/components/shared/status-dot';
|
import { StatusDot } from '@/components/shared/status-dot';
|
||||||
import { useVirtualMachines, usePowerAction } from '@/hooks/use-instances';
|
import { useVirtualMachines, usePowerAction } from '@/hooks/use-instances';
|
||||||
import InstanceActionDialogs, { type InstanceAction } from './InstanceActionDialogs';
|
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||||
import { vmTypeLabel } from '@/lib/format';
|
import { vmTypeLabel } from '@/lib/format';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -62,13 +62,25 @@ export default function InstanceDetailPage() {
|
|||||||
const isQemu = type === 'qemu';
|
const isQemu = type === 'qemu';
|
||||||
const TypeIcon = type === 'qemu' ? Monitor : Container;
|
const TypeIcon = type === 'qemu' ? Monitor : Container;
|
||||||
const [dialog, setDialog] = useState<InstanceAction>(null);
|
const [dialog, setDialog] = useState<InstanceAction>(null);
|
||||||
|
const [pendingPower, setPendingPower] = useState<PowerAction | null>(null);
|
||||||
|
|
||||||
const handlePower = (action: string) => {
|
const handlePower = (action: string) => {
|
||||||
|
setPendingPower(action as PowerAction);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPower = () => {
|
||||||
|
if (!pendingPower) return;
|
||||||
power.mutate(
|
power.mutate(
|
||||||
{ action },
|
{ action: pendingPower },
|
||||||
{
|
{
|
||||||
onSuccess: () => toast.success(`${action} sent successfully`),
|
onSuccess: () => {
|
||||||
onError: (err) => toast.error(err.message),
|
toast.success(`${pendingPower} sent successfully`);
|
||||||
|
setPendingPower(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
setPendingPower(null);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -246,6 +258,14 @@ export default function InstanceDetailPage() {
|
|||||||
name={vm?.name}
|
name={vm?.name}
|
||||||
description={vm?.description}
|
description={vm?.description}
|
||||||
/>
|
/>
|
||||||
|
<PowerConfirmDialog
|
||||||
|
open={pendingPower !== null}
|
||||||
|
onClose={() => setPendingPower(null)}
|
||||||
|
onConfirm={confirmPower}
|
||||||
|
action={pendingPower}
|
||||||
|
vmName={vm?.name}
|
||||||
|
isPending={power.isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Disc,
|
Disc,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import InstanceActionDialogs, { type InstanceAction } from './InstanceActionDialogs';
|
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -113,13 +113,25 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
|||||||
const isRunning = vm.status === 'running';
|
const isRunning = vm.status === 'running';
|
||||||
const isQemu = vm.type === 'qemu';
|
const isQemu = vm.type === 'qemu';
|
||||||
const [dialog, setDialog] = useState<InstanceAction>(null);
|
const [dialog, setDialog] = useState<InstanceAction>(null);
|
||||||
|
const [pendingPower, setPendingPower] = useState<PowerAction | null>(null);
|
||||||
|
|
||||||
const handleAction = (action: string) => {
|
const handleAction = (action: string) => {
|
||||||
|
setPendingPower(action as PowerAction);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPower = () => {
|
||||||
|
if (!pendingPower) return;
|
||||||
power.mutate(
|
power.mutate(
|
||||||
{ action },
|
{ action: pendingPower },
|
||||||
{
|
{
|
||||||
onSuccess: () => toast.success(`${action} sent for ${vm.name || vm.vmid}`),
|
onSuccess: () => {
|
||||||
onError: (err) => toast.error(err.message),
|
toast.success(`${pendingPower} sent for ${vm.name || vm.vmid}`);
|
||||||
|
setPendingPower(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
setPendingPower(null);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -191,6 +203,14 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
|||||||
name={vm.name}
|
name={vm.name}
|
||||||
description={vm.description}
|
description={vm.description}
|
||||||
/>
|
/>
|
||||||
|
<PowerConfirmDialog
|
||||||
|
open={pendingPower !== null}
|
||||||
|
onClose={() => setPendingPower(null)}
|
||||||
|
onConfirm={confirmPower}
|
||||||
|
action={pendingPower}
|
||||||
|
vmName={vm.name}
|
||||||
|
isPending={power.isPending}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,7 +257,9 @@ export default function InstancesPage() {
|
|||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [nodeFilter, setNodeFilter] = useState<string>('all');
|
const [nodeFilter, setNodeFilter] = useState<string>(() => {
|
||||||
|
return localStorage.getItem('instances-node-filter') ?? 'all';
|
||||||
|
});
|
||||||
const [typeTab, setTypeTab] = useState<string>('all');
|
const [typeTab, setTypeTab] = useState<string>('all');
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
@@ -658,7 +680,7 @@ export default function InstancesPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={nodeFilter} onValueChange={v => { if (v !== null) setNodeFilter(v); }}>
|
<Select value={nodeFilter} onValueChange={v => { if (v !== null) { setNodeFilter(v); localStorage.setItem('instances-node-filter', v); } }}>
|
||||||
<SelectTrigger className="w-[130px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder={t('common.node', 'Node')} />
|
<SelectValue placeholder={t('common.node', 'Node')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ function SidebarContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex h-14 items-center gap-2 px-4">
|
<div className="flex h-20 items-center gap-2 px-4">
|
||||||
<img src="/logo.png" alt="PVEmanager" className="h-12 w-auto" />
|
<img src="/logo.png" alt="PVEmanager" className="h-16 w-auto" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
2
init.sql
2
init.sql
@@ -12,7 +12,7 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
-- Grant privileges
|
-- Grant privileges
|
||||||
GRANT ALL PRIVILEGES ON DATABASE serverpanel TO serverpanel;
|
GRANT ALL PRIVILEGES ON DATABASE pvemanager TO pvemanager;
|
||||||
|
|
||||||
-- ==============================================================================
|
-- ==============================================================================
|
||||||
-- USERS AND AUTHENTICATION
|
-- USERS AND AUTHENTICATION
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ server {
|
|||||||
server_name DOMAIN_NAME;
|
server_name DOMAIN_NAME;
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
access_log /var/log/nginx/serverpanel_access.log;
|
access_log /var/log/nginx/pvemanager_access.log;
|
||||||
error_log /var/log/nginx/serverpanel_error.log;
|
error_log /var/log/nginx/pvemanager_error.log;
|
||||||
|
|
||||||
# Let's Encrypt challenge (needed for SSL certificate obtaining)
|
# Let's Encrypt challenge (needed for SSL certificate obtaining)
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
@@ -22,14 +22,14 @@ server {
|
|||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
access_log off;
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy
|
# API proxy
|
||||||
location /api/ {
|
location /api/ {
|
||||||
limit_req zone=api burst=50 nodelay;
|
limit_req zone=api burst=50 nodelay;
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -39,7 +39,7 @@ server {
|
|||||||
|
|
||||||
# Proxmox API proxy
|
# Proxmox API proxy
|
||||||
location /proxmox/ {
|
location /proxmox/ {
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -50,7 +50,7 @@ server {
|
|||||||
|
|
||||||
# WebSocket support — tasks и terminal
|
# WebSocket support — tasks и terminal
|
||||||
location ~ ^/(ws|proxmox/ws)/ {
|
location ~ ^/(ws|proxmox/ws)/ {
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
@@ -64,7 +64,7 @@ server {
|
|||||||
|
|
||||||
# Backend static files (legacy, logo, etc.)
|
# Backend static files (legacy, logo, etc.)
|
||||||
location /static/ {
|
location /static/ {
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
proxy_cache_valid 200 1d;
|
proxy_cache_valid 200 1d;
|
||||||
expires 1d;
|
expires 1d;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
@@ -72,7 +72,7 @@ server {
|
|||||||
|
|
||||||
# SPA frontend — all other routes
|
# SPA frontend — all other routes
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://serverpanel-frontend:80;
|
proxy_pass http://pvemanager-frontend:80;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -41,8 +41,8 @@ server {
|
|||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
access_log /var/log/nginx/serverpanel_access.log;
|
access_log /var/log/nginx/pvemanager_access.log;
|
||||||
error_log /var/log/nginx/serverpanel_error.log;
|
error_log /var/log/nginx/pvemanager_error.log;
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
limit_req zone=general burst=20 nodelay;
|
limit_req zone=general burst=20 nodelay;
|
||||||
@@ -50,14 +50,14 @@ server {
|
|||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
access_log off;
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy
|
# API proxy
|
||||||
location /api/ {
|
location /api/ {
|
||||||
limit_req zone=api burst=50 nodelay;
|
limit_req zone=api burst=50 nodelay;
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -67,7 +67,7 @@ server {
|
|||||||
|
|
||||||
# Proxmox API proxy
|
# Proxmox API proxy
|
||||||
location /proxmox/ {
|
location /proxmox/ {
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -78,7 +78,7 @@ server {
|
|||||||
|
|
||||||
# WebSocket support — tasks и terminal
|
# WebSocket support — tasks и terminal
|
||||||
location ~ ^/(ws|proxmox/ws)/ {
|
location ~ ^/(ws|proxmox/ws)/ {
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
@@ -92,7 +92,7 @@ server {
|
|||||||
|
|
||||||
# Backend static files (legacy, logo, etc.)
|
# Backend static files (legacy, logo, etc.)
|
||||||
location /static/ {
|
location /static/ {
|
||||||
proxy_pass http://serverpanel-app:8000;
|
proxy_pass http://pvemanager-app:8000;
|
||||||
proxy_cache_valid 200 1d;
|
proxy_cache_valid 200 1d;
|
||||||
expires 1d;
|
expires 1d;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
@@ -100,7 +100,7 @@ server {
|
|||||||
|
|
||||||
# SPA frontend — all other routes
|
# SPA frontend — all other routes
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://serverpanel-frontend:80;
|
proxy_pass http://pvemanager-frontend:80;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
53
pve
53
pve
@@ -41,8 +41,8 @@ declare -a _CF_ARGS
|
|||||||
_resolve_compose() {
|
_resolve_compose() {
|
||||||
_CF_ARGS=("-f" "compose.yml")
|
_CF_ARGS=("-f" "compose.yml")
|
||||||
if [ -f "$PVE_DIR/compose.prod.yml" ] && \
|
if [ -f "$PVE_DIR/compose.prod.yml" ] && \
|
||||||
{ docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "serverpanel-nginx" || \
|
{ docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "pvemanager-nginx" || \
|
||||||
[ -f "$PVE_DIR/nginx/conf.d/serverpanel.conf" ]; }; then
|
[ -f "$PVE_DIR/nginx/conf.d/pvemanager.conf" ]; }; then
|
||||||
_CF_ARGS=("-f" "compose.yml" "-f" "compose.prod.yml")
|
_CF_ARGS=("-f" "compose.yml" "-f" "compose.prod.yml")
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -66,14 +66,14 @@ wait_for_containers() {
|
|||||||
info "Ожидание готовности контейнеров..."
|
info "Ожидание готовности контейнеров..."
|
||||||
while [ $elapsed -lt $timeout ]; do
|
while [ $elapsed -lt $timeout ]; do
|
||||||
# Проверяем, что все контейнеры запущены и healthy
|
# Проверяем, что все контейнеры запущены и healthy
|
||||||
local app_status=$(docker ps --filter "name=serverpanel-app" --format "{{.State}}")
|
local app_status=$(docker ps --filter "name=pvemanager-app" --format "{{.State}}")
|
||||||
local db_status=$(docker ps --filter "name=serverpanel-db" --format "{{.State}}")
|
local db_status=$(docker ps --filter "name=pvemanager-db" --format "{{.State}}")
|
||||||
local frontend_status=$(docker ps --filter "name=serverpanel-frontend" --format "{{.State}}")
|
local frontend_status=$(docker ps --filter "name=pvemanager-frontend" --format "{{.State}}")
|
||||||
|
|
||||||
if [ "$app_status" = "running" ] && [ "$db_status" = "running" ] && [ "$frontend_status" = "running" ]; then
|
if [ "$app_status" = "running" ] && [ "$db_status" = "running" ] && [ "$frontend_status" = "running" ]; then
|
||||||
# Проверяем healthcheck статусы
|
# Проверяем healthcheck статусы
|
||||||
local app_health=$(docker inspect serverpanel-app --format='{{.State.Health.Status}}' 2>/dev/null)
|
local app_health=$(docker inspect pvemanager-app --format='{{.State.Health.Status}}' 2>/dev/null)
|
||||||
local db_health=$(docker inspect serverpanel-db --format='{{.State.Health.Status}}' 2>/dev/null)
|
local db_health=$(docker inspect pvemanager-db --format='{{.State.Health.Status}}' 2>/dev/null)
|
||||||
|
|
||||||
if [ "$app_health" = "healthy" ] && [ "$db_health" = "healthy" ]; then
|
if [ "$app_health" = "healthy" ] && [ "$db_health" = "healthy" ]; then
|
||||||
ok "Все контейнеры готовы"
|
ok "Все контейнеры готовы"
|
||||||
@@ -129,15 +129,16 @@ cmd_stop() {
|
|||||||
ok "Панель остановлена"
|
ok "Панель остановлена"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── pve restart (полная пересборка) ───────────────────────────────────────────
|
# ── pve restart (быстрая пересборка с кешем) ─────────────────────────────────
|
||||||
cmd_restart() {
|
cmd_restart() {
|
||||||
check_docker; check_pve_dir
|
check_docker; check_pve_dir
|
||||||
step "Остановка контейнеров"
|
step "Остановка контейнеров"
|
||||||
pve_dc down
|
pve_dc down
|
||||||
|
|
||||||
step "Пересборка образа app (без кеша)"
|
step "Пересборка образов (с кешем, BuildKit, параллельно)"
|
||||||
if ! pve_dc build --no-cache app; then
|
if ! DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
|
||||||
die "Не удалось пересобрать образ app"
|
pve_dc build --parallel; then
|
||||||
|
die "Не удалось пересобрать образы"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "Запуск контейнеров"
|
step "Запуск контейнеров"
|
||||||
@@ -157,6 +158,30 @@ cmd_restart() {
|
|||||||
pve_dc ps
|
pve_dc ps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── pve rebuild (полная пересборка БЕЗ кеша — медленно, использовать редко) ──
|
||||||
|
cmd_rebuild() {
|
||||||
|
check_docker; check_pve_dir
|
||||||
|
step "Остановка контейнеров"
|
||||||
|
pve_dc down
|
||||||
|
|
||||||
|
step "Полная пересборка образов (--no-cache, --pull)"
|
||||||
|
warn "Это займёт несколько минут — кеш слоёв игнорируется."
|
||||||
|
if ! DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
|
||||||
|
pve_dc build --no-cache --pull --parallel; then
|
||||||
|
die "Не удалось пересобрать образы"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Запуск контейнеров"
|
||||||
|
if ! pve_dc up -d; then
|
||||||
|
die "Не удалось запустить панель после пересборки"
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_containers
|
||||||
|
ok "Панель полностью пересобрана и запущена"
|
||||||
|
echo ""
|
||||||
|
pve_dc ps
|
||||||
|
}
|
||||||
|
|
||||||
# ── pve update ────────────────────────────────────────────────────────────────
|
# ── pve update ────────────────────────────────────────────────────────────────
|
||||||
cmd_update() {
|
cmd_update() {
|
||||||
check_docker; check_pve_dir
|
check_docker; check_pve_dir
|
||||||
@@ -199,11 +224,11 @@ cmd_backup() {
|
|||||||
pg_pass=$(grep -E '^POSTGRES_PASSWORD=' "$PVE_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"')
|
pg_pass=$(grep -E '^POSTGRES_PASSWORD=' "$PVE_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"')
|
||||||
local dump_ok=false
|
local dump_ok=false
|
||||||
|
|
||||||
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "serverpanel-db"; then
|
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "pvemanager-db"; then
|
||||||
if docker exec -i \
|
if docker exec -i \
|
||||||
-e PGPASSWORD="$pg_pass" \
|
-e PGPASSWORD="$pg_pass" \
|
||||||
serverpanel-db \
|
pvemanager-db \
|
||||||
pg_dump -U serverpanel -d serverpanel \
|
pg_dump -U pvemanager -d pvemanager \
|
||||||
> "$tmp_dir/db_dump.sql" 2>/dev/null; then
|
> "$tmp_dir/db_dump.sql" 2>/dev/null; then
|
||||||
dump_ok=true
|
dump_ok=true
|
||||||
ok "Дамп БД: $(du -sh "$tmp_dir/db_dump.sql" | cut -f1)"
|
ok "Дамп БД: $(du -sh "$tmp_dir/db_dump.sql" | cut -f1)"
|
||||||
|
|||||||
Reference in New Issue
Block a user