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 для минимального размера образа
|
||||
FROM python:3.12-alpine AS builder
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Multi-stage build на python:3.12-slim (glibc) — wheels ставятся бинарно,
|
||||
# никакого rust/cargo не требуется → сборка в разы быстрее.
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
gcc \
|
||||
musl-dev \
|
||||
postgresql-dev \
|
||||
libffi-dev \
|
||||
openssl-dev \
|
||||
cargo \
|
||||
rust
|
||||
# Минимальные build-зависимости (на случай если какой-то пакет придётся компилировать)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --user --no-warn-script-location -r requirements.txt
|
||||
|
||||
# Final stage - минимальный образ
|
||||
FROM python:3.12-alpine
|
||||
# BuildKit-кеш для pip — повторные сборки почти мгновенные
|
||||
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 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/app \
|
||||
PATH=/root/.local/bin:$PATH
|
||||
PATH=/root/.local/bin:$PATH \
|
||||
TZ=Asia/Tashkent
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
libpq \
|
||||
# Только runtime-зависимости. docker.io даёт CLI `docker` для self-update через сокет.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
openssh-client \
|
||||
curl \
|
||||
git \
|
||||
docker-cli \
|
||||
docker-compose \
|
||||
libstdc++ \
|
||||
docker.io \
|
||||
tzdata \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& 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 \
|
||||
&& git config --global --add safe.directory /project
|
||||
|
||||
# Copy Python dependencies from builder
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
|
||||
# Copy application code
|
||||
COPY app ./app
|
||||
COPY migrations ./migrations
|
||||
COPY init_admin.py .
|
||||
|
||||
# Health check с быстрой проверкой
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=2 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=5 \
|
||||
CMD curl -fs http://localhost:8000/health || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Оптимизированный запуск с preload для быстрого старта
|
||||
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:
|
||||
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(
|
||||
db=db, action='reinstall',
|
||||
resource_type='lxc' if is_lxc else 'vm', resource_id=vmid,
|
||||
|
||||
@@ -28,9 +28,9 @@ class Settings(BaseSettings):
|
||||
# Database settings
|
||||
DB_HOST: str = Field(default="localhost", env="DB_HOST")
|
||||
DB_PORT: int = Field(default=5432, env="DB_PORT")
|
||||
DB_USER: str = Field(default="serverpanel", env="DB_USER")
|
||||
DB_PASSWORD: str = Field(default="serverpanel", env="DB_PASSWORD")
|
||||
DB_NAME: str = Field(default="serverpanel", env="DB_NAME")
|
||||
DB_USER: str = Field(default="pvemanager", env="DB_USER")
|
||||
DB_PASSWORD: str = Field(default="pvemanager", env="DB_PASSWORD")
|
||||
DB_NAME: str = Field(default="pvemanager", env="DB_NAME")
|
||||
DATABASE_URL: Optional[str] = Field(default=None, env="DATABASE_URL")
|
||||
|
||||
# Security settings
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: serverpanel-nginx
|
||||
container_name: pvemanager-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
frontend:
|
||||
condition: service_started
|
||||
networks:
|
||||
- serverpanel-network
|
||||
- pvemanager-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "nginx", "-t"]
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot:latest
|
||||
container_name: serverpanel-certbot
|
||||
container_name: pvemanager-certbot
|
||||
volumes:
|
||||
- ./nginx/certbot/conf:/etc/letsencrypt
|
||||
- ./nginx/certbot/www:/var/www/certbot
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
depends_on:
|
||||
- nginx
|
||||
networks:
|
||||
- serverpanel-network
|
||||
- pvemanager-network
|
||||
restart: "no"
|
||||
profiles:
|
||||
- ssl
|
||||
|
||||
38
compose.yml
38
compose.yml
@@ -1,13 +1,13 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: serverpanel-db
|
||||
container_name: pvemanager-db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: serverpanel
|
||||
POSTGRES_USER: serverpanel
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-serverpanel_secure_password}
|
||||
POSTGRES_DB: pvemanager
|
||||
POSTGRES_USER: pvemanager
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pvemanager_secure_password}
|
||||
TZ: ${TZ:-Asia/Tashkent}
|
||||
# Оптимизация для быстрого старта
|
||||
POSTGRES_INITDB_ARGS: "--no-sync"
|
||||
@@ -16,15 +16,15 @@ services:
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U serverpanel -d serverpanel"]
|
||||
test: ["CMD-SHELL", "pg_isready -U pvemanager -d pvemanager"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- serverpanel-network
|
||||
# Оптимизация производительности БД
|
||||
command: |
|
||||
- pvemanager-network
|
||||
# Оптимизация производительности БД (>- склеит в одну строку через пробел)
|
||||
command: >-
|
||||
postgres
|
||||
-c shared_buffers=256MB
|
||||
-c max_connections=200
|
||||
@@ -39,9 +39,9 @@ services:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
image: server-panel-app:latest
|
||||
image: pvemanager-app:latest
|
||||
pull_policy: build
|
||||
container_name: serverpanel-app
|
||||
container_name: pvemanager-app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
restart: unless-stopped
|
||||
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
|
||||
- TZ=${TZ:-Asia/Tashkent}
|
||||
- PROJECT_ROOT=/project
|
||||
@@ -72,12 +72,12 @@ services:
|
||||
- UVICORN_WORKERS=1
|
||||
- UVICORN_TIMEOUT_KEEP_ALIVE=30
|
||||
networks:
|
||||
- serverpanel-network
|
||||
- pvemanager-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 15s
|
||||
test: ["CMD", "curl", "-fs", "http://localhost:8000/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 2
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
# Ограничение ресурсов для стабильности
|
||||
deploy:
|
||||
@@ -93,9 +93,9 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
image: server-panel-frontend:latest
|
||||
image: pvemanager-frontend:latest
|
||||
pull_policy: build
|
||||
container_name: serverpanel-frontend
|
||||
container_name: pvemanager-frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
@@ -103,7 +103,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- serverpanel-network
|
||||
- pvemanager-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -115,5 +115,5 @@ volumes:
|
||||
logs:
|
||||
|
||||
networks:
|
||||
serverpanel-network:
|
||||
pvemanager-network:
|
||||
driver: bridge
|
||||
|
||||
57
deploy.sh
57
deploy.sh
@@ -253,7 +253,7 @@ create_env_file() {
|
||||
cat > init.sql << 'EOF'
|
||||
-- PVEmanager Database Initialization
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
GRANT ALL PRIVILEGES ON DATABASE serverpanel TO serverpanel;
|
||||
GRANT ALL PRIVILEGES ON DATABASE pvemanager TO pvemanager;
|
||||
EOF
|
||||
print_success "init.sql file created"
|
||||
fi
|
||||
@@ -264,7 +264,7 @@ EOF
|
||||
print_info "Creating default .env.example..."
|
||||
cat > .env.example << 'EOF'
|
||||
# Database Configuration
|
||||
POSTGRES_PASSWORD=serverpanel_secure_password
|
||||
POSTGRES_PASSWORD=pvemanager_secure_password
|
||||
|
||||
# Timezone
|
||||
TZ=Asia/Tashkent
|
||||
@@ -285,10 +285,10 @@ LOG_LEVEL=INFO
|
||||
# Database
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_USER=serverpanel
|
||||
DB_PASSWORD=serverpanel_secure_password
|
||||
DB_NAME=serverpanel
|
||||
DATABASE_URL=postgresql://serverpanel:serverpanel_secure_password@db:5432/serverpanel
|
||||
DB_USER=pvemanager
|
||||
DB_PASSWORD=pvemanager_secure_password
|
||||
DB_NAME=pvemanager
|
||||
DATABASE_URL=postgresql://pvemanager:pvemanager_secure_password@db:5432/pvemanager
|
||||
|
||||
# JWT
|
||||
SECRET_KEY=your-very-secure-secret-key-change-this-in-production-minimum-32-chars
|
||||
@@ -312,7 +312,7 @@ EOF
|
||||
cp .env.example .env
|
||||
|
||||
# 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"
|
||||
else
|
||||
@@ -327,7 +327,7 @@ EOF
|
||||
cp backend/.env.example backend/.env
|
||||
|
||||
# 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
|
||||
|
||||
print_success "backend/.env file created"
|
||||
@@ -351,16 +351,16 @@ setup_nginx_config() {
|
||||
|
||||
if [ "$use_ssl" = true ]; then
|
||||
# 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"
|
||||
else
|
||||
# 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"
|
||||
fi
|
||||
|
||||
# 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}"
|
||||
}
|
||||
@@ -431,9 +431,10 @@ deploy_with_nginx() {
|
||||
docker compose -f compose.yml -f compose.prod.yml down --remove-orphans 2>/dev/null || true
|
||||
docker network prune -f 2>/dev/null || true
|
||||
|
||||
# Build images locally first
|
||||
print_info "Building Docker images..."
|
||||
docker compose -f compose.yml build
|
||||
# Build images locally first (parallel + pull base images)
|
||||
print_info "Building Docker images (parallel)..."
|
||||
DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
|
||||
docker compose -f compose.yml build --parallel --pull
|
||||
|
||||
if [ "$use_ssl" = true ]; then
|
||||
# Setup HTTP config first for certificate challenge
|
||||
@@ -494,11 +495,11 @@ deploy_with_nginx() {
|
||||
print_info "Starting services without SSL..."
|
||||
|
||||
# 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..."
|
||||
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..."
|
||||
sleep 10
|
||||
|
||||
@@ -523,9 +524,11 @@ deploy_standalone() {
|
||||
docker compose -f compose.yml down --remove-orphans 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..."
|
||||
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"
|
||||
}
|
||||
@@ -657,7 +660,7 @@ quick_deploy_standalone() {
|
||||
if [ ! -f .env ]; then
|
||||
if [ -f .env.example ]; then
|
||||
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
|
||||
create_default_env "${RANDOM_DB_PASSWORD}"
|
||||
fi
|
||||
@@ -666,7 +669,7 @@ quick_deploy_standalone() {
|
||||
if [ ! -f backend/.env ]; then
|
||||
if [ -f backend/.env.example ]; then
|
||||
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
|
||||
else
|
||||
create_default_backend_env "${RANDOM_DB_PASSWORD}" "${RANDOM_SECRET_KEY}"
|
||||
@@ -703,7 +706,7 @@ quick_deploy_nginx() {
|
||||
if [ ! -f .env ]; then
|
||||
if [ -f .env.example ]; then
|
||||
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
|
||||
create_default_env "${RANDOM_DB_PASSWORD}"
|
||||
fi
|
||||
@@ -712,7 +715,7 @@ quick_deploy_nginx() {
|
||||
if [ ! -f backend/.env ]; then
|
||||
if [ -f backend/.env.example ]; then
|
||||
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
|
||||
else
|
||||
create_default_backend_env "${RANDOM_DB_PASSWORD}" "${RANDOM_SECRET_KEY}"
|
||||
@@ -730,7 +733,7 @@ quick_deploy_nginx() {
|
||||
}
|
||||
|
||||
create_default_env() {
|
||||
local db_password="${1:-serverpanel_secure_password}"
|
||||
local db_password="${1:-pvemanager_secure_password}"
|
||||
cat > .env << EOF
|
||||
POSTGRES_PASSWORD=${db_password}
|
||||
TZ=Asia/Tashkent
|
||||
@@ -739,7 +742,7 @@ EOF
|
||||
}
|
||||
|
||||
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}"
|
||||
cat > backend/.env << EOF
|
||||
PANEL_NAME=PVEmanager
|
||||
@@ -747,10 +750,10 @@ DEBUG=false
|
||||
LOG_LEVEL=INFO
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_USER=serverpanel
|
||||
DB_USER=pvemanager
|
||||
DB_PASSWORD=${db_password}
|
||||
DB_NAME=serverpanel
|
||||
DATABASE_URL=postgresql://serverpanel:${db_password}@db:5432/serverpanel
|
||||
DB_NAME=pvemanager
|
||||
DATABASE_URL=postgresql://pvemanager:${db_password}@db:5432/pvemanager
|
||||
SECRET_KEY=${secret_key}
|
||||
ALGORITHM=HS256
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
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 . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ server {
|
||||
}
|
||||
|
||||
# Settings API proxy
|
||||
location /settings/ {
|
||||
proxy_pass http://app:8000/settings/;
|
||||
location /settings/api/ {
|
||||
proxy_pass http://app:8000/settings/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -40,8 +40,8 @@ server {
|
||||
}
|
||||
|
||||
# Templates API proxy
|
||||
location /templates/ {
|
||||
proxy_pass http://app:8000/templates/;
|
||||
location /templates/api/ {
|
||||
proxy_pass http://app:8000/templates/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -50,8 +50,8 @@ server {
|
||||
}
|
||||
|
||||
# IPAM API proxy
|
||||
location /ipam/ {
|
||||
proxy_pass http://app:8000/ipam/;
|
||||
location /ipam/api/ {
|
||||
proxy_pass http://app:8000/ipam/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -60,8 +60,8 @@ server {
|
||||
}
|
||||
|
||||
# Logs API proxy
|
||||
location /logs/ {
|
||||
proxy_pass http://app:8000/logs/;
|
||||
location /logs/api/ {
|
||||
proxy_pass http://app:8000/logs/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -70,8 +70,8 @@ server {
|
||||
}
|
||||
|
||||
# Admin API proxy
|
||||
location /admin/ {
|
||||
proxy_pass http://app:8000/admin/;
|
||||
location /admin/api/ {
|
||||
proxy_pass http://app:8000/admin/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
|
||||
export type PowerAction = 'start' | 'stop' | 'restart' | 'shutdown';
|
||||
|
||||
export type InstanceAction =
|
||||
| 'clone'
|
||||
| 'reinstall'
|
||||
@@ -536,3 +538,105 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
|
||||
</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';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
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 { toast } from 'sonner';
|
||||
|
||||
@@ -62,13 +62,25 @@ export default function InstanceDetailPage() {
|
||||
const isQemu = type === 'qemu';
|
||||
const TypeIcon = type === 'qemu' ? Monitor : Container;
|
||||
const [dialog, setDialog] = useState<InstanceAction>(null);
|
||||
const [pendingPower, setPendingPower] = useState<PowerAction | null>(null);
|
||||
|
||||
const handlePower = (action: string) => {
|
||||
setPendingPower(action as PowerAction);
|
||||
};
|
||||
|
||||
const confirmPower = () => {
|
||||
if (!pendingPower) return;
|
||||
power.mutate(
|
||||
{ action },
|
||||
{ action: pendingPower },
|
||||
{
|
||||
onSuccess: () => toast.success(`${action} sent successfully`),
|
||||
onError: (err) => toast.error(err.message),
|
||||
onSuccess: () => {
|
||||
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}
|
||||
description={vm?.description}
|
||||
/>
|
||||
<PowerConfirmDialog
|
||||
open={pendingPower !== null}
|
||||
onClose={() => setPendingPower(null)}
|
||||
onConfirm={confirmPower}
|
||||
action={pendingPower}
|
||||
vmName={vm?.name}
|
||||
isPending={power.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
FileText,
|
||||
Disc,
|
||||
} 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 { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -113,13 +113,25 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
const isRunning = vm.status === 'running';
|
||||
const isQemu = vm.type === 'qemu';
|
||||
const [dialog, setDialog] = useState<InstanceAction>(null);
|
||||
const [pendingPower, setPendingPower] = useState<PowerAction | null>(null);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
setPendingPower(action as PowerAction);
|
||||
};
|
||||
|
||||
const confirmPower = () => {
|
||||
if (!pendingPower) return;
|
||||
power.mutate(
|
||||
{ action },
|
||||
{ action: pendingPower },
|
||||
{
|
||||
onSuccess: () => toast.success(`${action} sent for ${vm.name || vm.vmid}`),
|
||||
onError: (err) => toast.error(err.message),
|
||||
onSuccess: () => {
|
||||
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}
|
||||
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 [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 [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
@@ -658,7 +680,7 @@ export default function InstancesPage() {
|
||||
</SelectContent>
|
||||
</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]">
|
||||
<SelectValue placeholder={t('common.node', 'Node')} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -87,8 +87,8 @@ function SidebarContent() {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<img src="/logo.png" alt="PVEmanager" className="h-12 w-auto" />
|
||||
<div className="flex h-20 items-center gap-2 px-4">
|
||||
<img src="/logo.png" alt="PVEmanager" className="h-16 w-auto" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
2
init.sql
2
init.sql
@@ -12,7 +12,7 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Grant privileges
|
||||
GRANT ALL PRIVILEGES ON DATABASE serverpanel TO serverpanel;
|
||||
GRANT ALL PRIVILEGES ON DATABASE pvemanager TO pvemanager;
|
||||
|
||||
-- ==============================================================================
|
||||
-- USERS AND AUTHENTICATION
|
||||
|
||||
@@ -7,8 +7,8 @@ server {
|
||||
server_name DOMAIN_NAME;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/serverpanel_access.log;
|
||||
error_log /var/log/nginx/serverpanel_error.log;
|
||||
access_log /var/log/nginx/pvemanager_access.log;
|
||||
error_log /var/log/nginx/pvemanager_error.log;
|
||||
|
||||
# Let's Encrypt challenge (needed for SSL certificate obtaining)
|
||||
location /.well-known/acme-challenge/ {
|
||||
@@ -22,14 +22,14 @@ server {
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://serverpanel-app:8000;
|
||||
proxy_pass http://pvemanager-app:8000;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
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_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -39,7 +39,7 @@ server {
|
||||
|
||||
# Proxmox API proxy
|
||||
location /proxmox/ {
|
||||
proxy_pass http://serverpanel-app:8000;
|
||||
proxy_pass http://pvemanager-app:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -50,7 +50,7 @@ server {
|
||||
|
||||
# WebSocket support — tasks и terminal
|
||||
location ~ ^/(ws|proxmox/ws)/ {
|
||||
proxy_pass http://serverpanel-app:8000;
|
||||
proxy_pass http://pvemanager-app:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
@@ -64,7 +64,7 @@ server {
|
||||
|
||||
# Backend static files (legacy, logo, etc.)
|
||||
location /static/ {
|
||||
proxy_pass http://serverpanel-app:8000;
|
||||
proxy_pass http://pvemanager-app:8000;
|
||||
proxy_cache_valid 200 1d;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
@@ -72,7 +72,7 @@ server {
|
||||
|
||||
# SPA frontend — all other routes
|
||||
location / {
|
||||
proxy_pass http://serverpanel-frontend:80;
|
||||
proxy_pass http://pvemanager-frontend:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -41,8 +41,8 @@ server {
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/serverpanel_access.log;
|
||||
error_log /var/log/nginx/serverpanel_error.log;
|
||||
access_log /var/log/nginx/pvemanager_access.log;
|
||||
error_log /var/log/nginx/pvemanager_error.log;
|
||||
|
||||
# Rate limiting
|
||||
limit_req zone=general burst=20 nodelay;
|
||||
@@ -50,14 +50,14 @@ server {
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://serverpanel-app:8000;
|
||||
proxy_pass http://pvemanager-app:8000;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
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_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -67,7 +67,7 @@ server {
|
||||
|
||||
# Proxmox API proxy
|
||||
location /proxmox/ {
|
||||
proxy_pass http://serverpanel-app:8000;
|
||||
proxy_pass http://pvemanager-app:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -78,7 +78,7 @@ server {
|
||||
|
||||
# WebSocket support — tasks и terminal
|
||||
location ~ ^/(ws|proxmox/ws)/ {
|
||||
proxy_pass http://serverpanel-app:8000;
|
||||
proxy_pass http://pvemanager-app:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
@@ -92,7 +92,7 @@ server {
|
||||
|
||||
# Backend static files (legacy, logo, etc.)
|
||||
location /static/ {
|
||||
proxy_pass http://serverpanel-app:8000;
|
||||
proxy_pass http://pvemanager-app:8000;
|
||||
proxy_cache_valid 200 1d;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
@@ -100,7 +100,7 @@ server {
|
||||
|
||||
# SPA frontend — all other routes
|
||||
location / {
|
||||
proxy_pass http://serverpanel-frontend:80;
|
||||
proxy_pass http://pvemanager-frontend:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
53
pve
53
pve
@@ -41,8 +41,8 @@ declare -a _CF_ARGS
|
||||
_resolve_compose() {
|
||||
_CF_ARGS=("-f" "compose.yml")
|
||||
if [ -f "$PVE_DIR/compose.prod.yml" ] && \
|
||||
{ docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "serverpanel-nginx" || \
|
||||
[ -f "$PVE_DIR/nginx/conf.d/serverpanel.conf" ]; }; then
|
||||
{ docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "pvemanager-nginx" || \
|
||||
[ -f "$PVE_DIR/nginx/conf.d/pvemanager.conf" ]; }; then
|
||||
_CF_ARGS=("-f" "compose.yml" "-f" "compose.prod.yml")
|
||||
fi
|
||||
}
|
||||
@@ -66,14 +66,14 @@ wait_for_containers() {
|
||||
info "Ожидание готовности контейнеров..."
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
# Проверяем, что все контейнеры запущены и healthy
|
||||
local app_status=$(docker ps --filter "name=serverpanel-app" --format "{{.State}}")
|
||||
local db_status=$(docker ps --filter "name=serverpanel-db" --format "{{.State}}")
|
||||
local frontend_status=$(docker ps --filter "name=serverpanel-frontend" --format "{{.State}}")
|
||||
local app_status=$(docker ps --filter "name=pvemanager-app" --format "{{.State}}")
|
||||
local db_status=$(docker ps --filter "name=pvemanager-db" --format "{{.State}}")
|
||||
local frontend_status=$(docker ps --filter "name=pvemanager-frontend" --format "{{.State}}")
|
||||
|
||||
if [ "$app_status" = "running" ] && [ "$db_status" = "running" ] && [ "$frontend_status" = "running" ]; then
|
||||
# Проверяем healthcheck статусы
|
||||
local app_health=$(docker inspect serverpanel-app --format='{{.State.Health.Status}}' 2>/dev/null)
|
||||
local db_health=$(docker inspect serverpanel-db --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 pvemanager-db --format='{{.State.Health.Status}}' 2>/dev/null)
|
||||
|
||||
if [ "$app_health" = "healthy" ] && [ "$db_health" = "healthy" ]; then
|
||||
ok "Все контейнеры готовы"
|
||||
@@ -129,15 +129,16 @@ cmd_stop() {
|
||||
ok "Панель остановлена"
|
||||
}
|
||||
|
||||
# ── pve restart (полная пересборка) ───────────────────────────────────────────
|
||||
# ── pve restart (быстрая пересборка с кешем) ─────────────────────────────────
|
||||
cmd_restart() {
|
||||
check_docker; check_pve_dir
|
||||
step "Остановка контейнеров"
|
||||
pve_dc down
|
||||
|
||||
step "Пересборка образа app (без кеша)"
|
||||
if ! pve_dc build --no-cache app; then
|
||||
die "Не удалось пересобрать образ app"
|
||||
step "Пересборка образов (с кешем, BuildKit, параллельно)"
|
||||
if ! DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 \
|
||||
pve_dc build --parallel; then
|
||||
die "Не удалось пересобрать образы"
|
||||
fi
|
||||
|
||||
step "Запуск контейнеров"
|
||||
@@ -157,6 +158,30 @@ cmd_restart() {
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
cmd_update() {
|
||||
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 '"')
|
||||
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 \
|
||||
-e PGPASSWORD="$pg_pass" \
|
||||
serverpanel-db \
|
||||
pg_dump -U serverpanel -d serverpanel \
|
||||
pvemanager-db \
|
||||
pg_dump -U pvemanager -d pvemanager \
|
||||
> "$tmp_dir/db_dump.sql" 2>/dev/null; then
|
||||
dump_ok=true
|
||||
ok "Дамп БД: $(du -sh "$tmp_dir/db_dump.sql" | cut -f1)"
|
||||
|
||||
Reference in New Issue
Block a user