fix: исправления и улучшения от 30.04.2026
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:
markmorado
2026-04-30 21:58:44 +02:00
parent 79d6864d43
commit adcc86f30a
18 changed files with 403 additions and 153 deletions

View File

@@ -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 \ tzdata \
libstdc++ \ ca-certificates \
tzdata \ && 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"]

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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,32 +16,32 @@ 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
-c work_mem=4MB -c work_mem=4MB
-c maintenance_work_mem=64MB -c maintenance_work_mem=64MB
-c effective_cache_size=512MB -c effective_cache_size=512MB
-c checkpoint_completion_target=0.9 -c checkpoint_completion_target=0.9
-c wal_buffers=16MB -c wal_buffers=16MB
-c random_page_cost=1.1 -c random_page_cost=1.1
app: app:
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

View File

@@ -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
View File

@@ -0,0 +1,13 @@
node_modules
dist
.git
.gitignore
.vscode
.idea
*.log
.env
.env.*
!.env.example
*.md
coverage
.DS_Store

View File

@@ -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

View File

@@ -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;

View File

@@ -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"
}, },

View File

@@ -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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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

View File

@@ -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;

View File

@@ -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
View File

@@ -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)"