feat(appstore): автосборка золотого LXC-шаблона из UI (Workstream B)
Автоматизирует ручную процедуру docs/golden-template.md: pveam download базового Debian → pct create временного CT → установка Docker/Compose → каталоги/сеть/logrotate → (опц.) проверка nginx → vzdump в vztmpl → удаление временного CT. При сбое недособранный CT очищается. - backend/app/appstore/golden_template.py — сборщик поверх SSH-инфраструктуры пайплайна (pct/pvesm/pveam/vzdump); идемпотентность по существующему vztmpl, версия базового шаблона берётся из pveam (не хардкодится) - appstore_engine: prepare_golden_template/_run_golden_template (фон, журнал AppOperation + WS), golden_template_status; собранный volid сохраняется в PanelSettings и предпочитается env при установке (_resolve_golden_template) - API: POST /api/appstore/golden-template, GET .../golden-template/status (app:manage / app:view) - Frontend: GoldenTemplateDialog (выбор сервера/ноды/хранилищ, force, живой прогресс опросом статуса) + admin-кнопка «Золотой шаблон» на странице App Store; хуки useGoldenTemplateStatus/useBuildGoldenTemplate; локали RU/EN Проверено: роуты зарегистрированы, golden_template_status и приоритет _resolve_golden_template (override→saved→env) корректны, tsc 0 ошибок. Полный e2e-прогон сборки на реальной ноде — по запросу. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,13 +16,15 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
from ...config import settings
|
||||
from ...db import SessionLocal, get_db
|
||||
from ...models import CatalogApp
|
||||
from ...models import CatalogApp, User
|
||||
from ...services import appstore_engine as engine
|
||||
from ...services.appstore_catalog import get_catalog_meta, sync_catalog
|
||||
|
||||
router = APIRouter()
|
||||
@@ -123,3 +125,41 @@ async def trigger_sync(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Синхронизация не удалась: {e}")
|
||||
return {"success": True, "stats": stats}
|
||||
|
||||
|
||||
# ── Золотой шаблон (Workstream B) ─────────────────────────────────────────────
|
||||
|
||||
class GoldenTemplateRequest(BaseModel):
|
||||
server_id: int
|
||||
node: Optional[str] = None
|
||||
template_storage: str = "local" # dir-storage для итогового vztmpl
|
||||
rootfs_storage: str = "local-lvm" # storage для rootfs временного CT
|
||||
bridge: str = "vmbr0"
|
||||
force: bool = False # пересобрать, даже если шаблон уже есть
|
||||
|
||||
|
||||
@router.post("/api/appstore/golden-template")
|
||||
def build_golden_template(
|
||||
req: GoldenTemplateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("app:manage")),
|
||||
):
|
||||
"""Собрать золотой LXC-шаблон на ноде (фоновая операция). Прогресс — по WebSocket."""
|
||||
try:
|
||||
result = engine.prepare_golden_template(
|
||||
db, current_user, server_id=req.server_id, node=req.node,
|
||||
template_storage=req.template_storage, rootfs_storage=req.rootfs_storage,
|
||||
bridge=req.bridge, force=req.force,
|
||||
)
|
||||
except engine.EngineError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return {"success": True, **result}
|
||||
|
||||
|
||||
@router.get("/api/appstore/golden-template/status")
|
||||
def golden_template_status(
|
||||
db: Session = Depends(get_db),
|
||||
_: object = Depends(PermissionChecker("app:view")),
|
||||
):
|
||||
"""Текущий шаблон (БД/env) и последняя операция сборки."""
|
||||
return engine.golden_template_status(db)
|
||||
|
||||
233
backend/app/appstore/golden_template.py
Normal file
233
backend/app/appstore/golden_template.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Автосборка золотого LXC-шаблона (Workstream B).
|
||||
|
||||
Автоматизирует ручную процедуру из docs/golden-template.md:
|
||||
pveam download базового Debian → pct create временного CT → установка
|
||||
Docker/compose → подготовка каталогов/сети/logrotate → (опц.) проверка nginx →
|
||||
vzdump в vztmpl → удаление временного CT.
|
||||
|
||||
Результат — volid вида "<storage>:vztmpl/golden-docker_12_<arch>.tar.zst",
|
||||
готовый для APPSTORE_GOLDEN_TEMPLATE.
|
||||
|
||||
Переиспользует SSH-инфраструктуру пайплайна установки (root@node,
|
||||
pct/pvesm/pveam/vzdump). Прогресс отдаётся через callback on_step(step, progress).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..api.proxmox._helpers import _get_proxmox_client, get_next_vmid
|
||||
from ..config import settings
|
||||
from ..models import ProxmoxServer
|
||||
from .pipeline import (
|
||||
PocError, StepCb, _node_ssh, _noop, _pct, _resolve_node, _run, _wait_ip,
|
||||
)
|
||||
|
||||
# Скрипты, исполняемые ВНУТРИ контейнера через `pct exec ... bash -lc`.
|
||||
# Важно: без одинарных кавычек (иначе сломают обёртку _shq в _pct).
|
||||
|
||||
_DOCKER_INSTALL = r"""
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl jq gnupg
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
systemctl enable docker
|
||||
"""
|
||||
|
||||
_PREP = r"""
|
||||
set -e
|
||||
mkdir -p /opt/app /opt/app-data
|
||||
docker network inspect tipi_main_network >/dev/null 2>&1 || docker network create tipi_main_network
|
||||
cat >/etc/docker/daemon.json <<EOF
|
||||
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
|
||||
EOF
|
||||
systemctl restart docker
|
||||
"""
|
||||
|
||||
_VALIDATE = r"""
|
||||
set -e
|
||||
mkdir -p /opt/app && cd /opt/app
|
||||
printf "services:\n web:\n image: nginx:alpine\n ports:\n - \"80:80\"\n" > docker-compose.yml
|
||||
docker compose up -d
|
||||
sleep 3
|
||||
curl -sf http://localhost:80 >/dev/null && echo OK
|
||||
docker compose down
|
||||
rm -f /opt/app/docker-compose.yml
|
||||
"""
|
||||
|
||||
_CLEANUP = r"""
|
||||
set -e
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
rm -f /opt/app/docker-compose.yml
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoldenSpec:
|
||||
server_id: int
|
||||
node: Optional[str] = None
|
||||
template_storage: str = "local" # dir-storage для базового и итогового vztmpl
|
||||
rootfs_storage: str = "local-lvm" # storage для rootfs временного CT
|
||||
bridge: str = "vmbr0"
|
||||
arch: Optional[str] = None # amd64|arm64; None → settings.APPSTORE_HOST_ARCH
|
||||
force: bool = False # пересобрать, даже если шаблон уже существует
|
||||
vmid: Optional[int] = None # если None — берётся следующий свободный
|
||||
cores: int = 2
|
||||
memory: int = 2048 # МБ
|
||||
swap: int = 512 # МБ
|
||||
disk: int = 8 # ГБ (rootfs временного CT)
|
||||
build_timeout: int = 1800 # сек на apt/docker install и vzdump
|
||||
validate: bool = True # тестовый nginx (DoD ТЗ 4.2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoldenResult:
|
||||
volid: str
|
||||
node: str
|
||||
status: str # "created" | "exists"
|
||||
|
||||
|
||||
def build_golden_template(db: Session, spec: GoldenSpec,
|
||||
on_step: StepCb = _noop) -> GoldenResult:
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == spec.server_id).first()
|
||||
if not server:
|
||||
raise PocError(f"Proxmox сервер id={spec.server_id} не найден")
|
||||
|
||||
on_step("Подключение к Proxmox...", 5)
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise PocError("Не удалось подключиться к Proxmox")
|
||||
|
||||
node = _resolve_node(client, spec.node)
|
||||
arch = (spec.arch or settings.APPSTORE_HOST_ARCH or "amd64").strip().lower()
|
||||
target_name = f"golden-docker_12_{arch}.tar.zst"
|
||||
target_volid = f"{spec.template_storage}:vztmpl/{target_name}"
|
||||
|
||||
ssh = _node_ssh(server, client)
|
||||
if not ssh.connect():
|
||||
raise PocError("SSH к ноде не установлен (нужен root-доступ к ноде)")
|
||||
|
||||
vmid: Optional[int] = None
|
||||
created_ct = False
|
||||
try:
|
||||
# Идемпотентность: шаблон уже есть и не запрошена пересборка.
|
||||
on_step("Проверка существующего шаблона...", 10)
|
||||
existing = _run(ssh, f"pvesm list {spec.template_storage} --content vztmpl",
|
||||
what="list vztmpl", timeout=60)
|
||||
if target_name in existing and not spec.force:
|
||||
on_step(f"Шаблон уже существует: {target_volid}", 100)
|
||||
return GoldenResult(volid=target_volid, node=node, status="exists")
|
||||
|
||||
# 1. Базовый шаблон Debian 12 — актуальное имя из pveam (не хардкодим версию).
|
||||
on_step("Поиск и загрузка базового шаблона Debian 12...", 20)
|
||||
_run(ssh, "pveam update", what="pveam update", timeout=120)
|
||||
base_name = _run(
|
||||
ssh,
|
||||
"pveam available --section system | awk '{print $2}' | "
|
||||
f"grep -E '^debian-12-standard_.*_{arch}\\.tar\\.zst$' | sort -V | tail -1",
|
||||
what="find debian template",
|
||||
).strip()
|
||||
if not base_name:
|
||||
raise PocError(f"Не найден базовый шаблон debian-12-standard для арх. {arch}")
|
||||
|
||||
have = _run(ssh, f"pvesm list {spec.template_storage} --content vztmpl",
|
||||
what="list vztmpl", timeout=60)
|
||||
if base_name not in have:
|
||||
_run(ssh, f"pveam download {spec.template_storage} {base_name}",
|
||||
what="pveam download", timeout=600)
|
||||
|
||||
# 2. Временный CT из базового шаблона.
|
||||
vmid = spec.vmid or get_next_vmid(db, server.id)
|
||||
on_step(f"Создание временного CT {vmid}...", 30)
|
||||
_run(
|
||||
ssh,
|
||||
f"pct create {vmid} {spec.template_storage}:vztmpl/{base_name} "
|
||||
f"--hostname golden-docker --unprivileged 1 "
|
||||
f"--features nesting=1,keyctl=1 "
|
||||
f"--cores {spec.cores} --memory {spec.memory} --swap {spec.swap} "
|
||||
f"--rootfs {spec.rootfs_storage}:{spec.disk} "
|
||||
f"--net0 name=eth0,bridge={spec.bridge},ip=dhcp",
|
||||
what="pct create", timeout=300,
|
||||
)
|
||||
created_ct = True
|
||||
|
||||
on_step("Запуск CT и ожидание сети...", 40)
|
||||
_run(ssh, f"pct start {vmid}", what="pct start", timeout=120)
|
||||
ip = _wait_ip(ssh, vmid, 120)
|
||||
if not ip:
|
||||
raise PocError("Временный CT не получил IP за 120с (нет сети для apt/docker)")
|
||||
|
||||
# 3. Установка Docker + утилит (долгий шаг).
|
||||
on_step("Установка Docker и Compose...", 55)
|
||||
_run(ssh, _pct(vmid, _DOCKER_INSTALL), what="install docker",
|
||||
timeout=spec.build_timeout)
|
||||
|
||||
# 4. Каталоги, docker-сеть, logrotate.
|
||||
on_step("Подготовка каталогов, сети и logrotate...", 72)
|
||||
_run(ssh, _pct(vmid, _PREP), what="prepare template", timeout=300)
|
||||
|
||||
# 5. Проверка DoD: тестовый nginx поднимается с первой попытки.
|
||||
if spec.validate:
|
||||
on_step("Проверка (тестовый nginx)...", 80)
|
||||
_run(ssh, _pct(vmid, _VALIDATE), what="validate nginx", timeout=300)
|
||||
|
||||
# 6. Очистка и остановка.
|
||||
on_step("Очистка временного состояния...", 86)
|
||||
_run(ssh, _pct(vmid, _CLEANUP), what="cleanup", timeout=120)
|
||||
_run(ssh, f"pct stop {vmid}", what="pct stop", timeout=120)
|
||||
|
||||
# 7. Экспорт rootfs в vztmpl через vzdump в каталог кэша storage.
|
||||
on_step("Экспорт шаблона (vzdump)...", 90)
|
||||
probe = _run(ssh, f"pvesm path {spec.template_storage}:vztmpl/probe",
|
||||
what="resolve vztmpl dir").strip()
|
||||
cache_dir = os.path.dirname(probe)
|
||||
if not cache_dir:
|
||||
raise PocError(f"Не удалось определить каталог vztmpl для {spec.template_storage}")
|
||||
_run(ssh, f"vzdump {vmid} --dumpdir {cache_dir} --mode stop --compress zstd",
|
||||
what="vzdump export", timeout=spec.build_timeout)
|
||||
|
||||
on_step("Переименование в золотой шаблон...", 96)
|
||||
artifact = _run(
|
||||
ssh,
|
||||
f"ls -t {cache_dir}/vzdump-lxc-{vmid}-*.tar.zst 2>/dev/null | head -1",
|
||||
what="find vzdump artifact",
|
||||
).strip()
|
||||
if not artifact:
|
||||
raise PocError("vzdump не создал ожидаемый архив шаблона")
|
||||
_run(ssh, f"mv -f {artifact} {cache_dir}/{target_name}",
|
||||
what="rename to golden")
|
||||
|
||||
# 8. Удаление временного CT.
|
||||
on_step("Удаление временного CT...", 98)
|
||||
_run(ssh, f"pct destroy {vmid} --purge 1", what="destroy temp CT", timeout=120)
|
||||
created_ct = False
|
||||
|
||||
on_step(f"Готово: {target_volid}", 100)
|
||||
return GoldenResult(volid=target_volid, node=node, status="created")
|
||||
except Exception:
|
||||
# При сбое пытаемся убрать недособранный временный CT, чтобы не мусорить.
|
||||
if created_ct and vmid is not None:
|
||||
try:
|
||||
_run(ssh, f"pct stop {vmid}", what="cleanup stop", timeout=60)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_run(ssh, f"pct destroy {vmid} --purge 1", what="cleanup destroy", timeout=120)
|
||||
except Exception as e:
|
||||
logger.warning(f"[golden-template] cleanup destroy {vmid}: {e}")
|
||||
raise
|
||||
finally:
|
||||
ssh.close()
|
||||
@@ -168,6 +168,7 @@ APP_OPERATION_LABELS = {
|
||||
"start": "Запуск приложения",
|
||||
"stop": "Остановка приложения",
|
||||
"restart": "Перезапуск приложения",
|
||||
"golden_template": "Подготовка золотого шаблона",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ from ..appstore.pipeline import (
|
||||
APP_DIR, APP_DATA_DIR, InstallSpec, PocError, _NAME_RE, _node_ssh, _shq,
|
||||
poc_install, poc_teardown, poc_update,
|
||||
)
|
||||
from ..appstore.golden_template import GoldenSpec, build_golden_template
|
||||
from ..api.proxmox._helpers import _get_proxmox_client, get_next_vmid, soft_delete_vm_instance
|
||||
from ..api.settings import get_setting, set_setting
|
||||
from ..config import settings
|
||||
from ..db import SessionLocal
|
||||
from ..ipam_service import IPAMService
|
||||
@@ -139,6 +141,88 @@ def build_env(app: CatalogApp, form_answers: dict) -> tuple[dict, dict]:
|
||||
return env, secrets_shown
|
||||
|
||||
|
||||
# ── Золотой шаблон (Workstream B) ─────────────────────────────────────────────
|
||||
|
||||
# Ключ PanelSettings, куда сохраняется volid собранного золотого шаблона.
|
||||
_GOLDEN_SETTING_KEY = "appstore_golden_template"
|
||||
|
||||
|
||||
def _resolve_golden_template(db, ostemplate: Optional[str] = None) -> Optional[str]:
|
||||
"""Определить golden vztmpl: явный override → сохранённый в БД → env-дефолт."""
|
||||
return (
|
||||
ostemplate
|
||||
or get_setting(db, _GOLDEN_SETTING_KEY)
|
||||
or settings.APPSTORE_GOLDEN_TEMPLATE
|
||||
)
|
||||
|
||||
|
||||
def prepare_golden_template(db, user, *, server_id: int, node: Optional[str] = None,
|
||||
template_storage: str = "local",
|
||||
rootfs_storage: str = "local-lvm",
|
||||
bridge: str = "vmbr0", force: bool = False) -> dict:
|
||||
"""Запустить сборку золотого шаблона в фоне. Возвращает operation_id."""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise EngineError("Proxmox сервер не найден")
|
||||
|
||||
op = _new_operation(db, None, "golden_template", user.id)
|
||||
_engine_executor.submit(
|
||||
_run_golden_template, op.id, user.id, server_id, node,
|
||||
template_storage, rootfs_storage, bridge, force,
|
||||
)
|
||||
return {"operation_id": op.id}
|
||||
|
||||
|
||||
def _run_golden_template(operation_id, user_id, server_id, node,
|
||||
template_storage, rootfs_storage, bridge, force):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
def on_step(step: str, progress: int) -> None:
|
||||
_record_step(operation_id, user_id, step, progress)
|
||||
|
||||
spec = GoldenSpec(
|
||||
server_id=server_id, node=node, template_storage=template_storage,
|
||||
rootfs_storage=rootfs_storage, bridge=bridge, force=force,
|
||||
)
|
||||
result = build_golden_template(db, spec, on_step)
|
||||
|
||||
# Сохраняем volid в БД — install() будет предпочитать его env-дефолту.
|
||||
try:
|
||||
set_setting(db, _GOLDEN_SETTING_KEY, result.volid,
|
||||
description="App Store: собранный золотой LXC-шаблон")
|
||||
except Exception as e:
|
||||
logger.warning(f"[appstore-engine] save golden template setting: {e}")
|
||||
|
||||
_finish_op(operation_id, user_id, "completed")
|
||||
try:
|
||||
broadcast_task_update(user_id, "appstore_golden_template_done",
|
||||
{"volid": result.volid, "node": result.node,
|
||||
"status": result.status})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"[appstore-engine] golden template failed: {e}")
|
||||
_finish_op(operation_id, user_id, "failed", error=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def golden_template_status(db) -> dict:
|
||||
"""Текущий шаблон (из БД/env) + последняя операция сборки."""
|
||||
op = (
|
||||
db.query(AppOperation)
|
||||
.filter(AppOperation.type == "golden_template")
|
||||
.order_by(AppOperation.started_at.desc())
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"configured_template": _resolve_golden_template(db),
|
||||
"env_template": settings.APPSTORE_GOLDEN_TEMPLATE,
|
||||
"saved_template": get_setting(db, _GOLDEN_SETTING_KEY),
|
||||
"last_operation": op.to_dict() if op else None,
|
||||
}
|
||||
|
||||
|
||||
# ── Установка ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _allocate_ipam(db, *, network_id: int, pool_id: Optional[int], name: str,
|
||||
@@ -189,9 +273,10 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
|
||||
if not _NAME_RE.match(name or ""):
|
||||
raise EngineError("Недопустимое имя приложения (a-z, 0-9, дефис, 1..63)")
|
||||
|
||||
golden = ostemplate or settings.APPSTORE_GOLDEN_TEMPLATE
|
||||
golden = _resolve_golden_template(db, ostemplate)
|
||||
if not golden:
|
||||
raise EngineError("Не настроен золотой шаблон LXC (APPSTORE_GOLDEN_TEMPLATE)")
|
||||
raise EngineError("Не настроен золотой шаблон LXC — подготовьте его "
|
||||
"(App Store → золотой шаблон) или задайте APPSTORE_GOLDEN_TEMPLATE")
|
||||
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
@@ -316,9 +401,10 @@ def retry_install(db, user, installed_app_id: int) -> dict:
|
||||
compose = app.compose_yaml if app else None
|
||||
if not compose:
|
||||
raise EngineError("compose приложения недоступен для повтора")
|
||||
golden = settings.APPSTORE_GOLDEN_TEMPLATE
|
||||
golden = _resolve_golden_template(db)
|
||||
if not golden:
|
||||
raise EngineError("Не настроен золотой шаблон LXC (APPSTORE_GOLDEN_TEMPLATE)")
|
||||
raise EngineError("Не настроен золотой шаблон LXC — подготовьте его "
|
||||
"(App Store → золотой шаблон) или задайте APPSTORE_GOLDEN_TEMPLATE")
|
||||
|
||||
env = ia.env_dict()
|
||||
ia.status = "installing"
|
||||
|
||||
@@ -2,13 +2,15 @@ import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Store, Search, RefreshCw, Loader2, Package } from 'lucide-react';
|
||||
import { Store, Search, RefreshCw, Loader2, Package, HardDriveDownload } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { useCatalog, useCatalogMeta, useInstalledApps, useSyncCatalog } from '@/hooks/use-appstore';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import GoldenTemplateDialog from './GoldenTemplateDialog';
|
||||
|
||||
export default function AppStorePage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,6 +18,8 @@ export default function AppStorePage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<string>('');
|
||||
const [source, setSource] = useState<string>('');
|
||||
const [goldenOpen, setGoldenOpen] = useState(false);
|
||||
const isAdmin = useAuthStore((s) => s.user?.is_admin ?? false);
|
||||
|
||||
const { data: catalog, isLoading } = useCatalog(search, category, source);
|
||||
const { data: meta } = useCatalogMeta();
|
||||
@@ -52,6 +56,12 @@ export default function AppStorePage() {
|
||||
<Package className="mr-2 h-4 w-4" />
|
||||
{t('appstore.my_apps')}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={() => setGoldenOpen(true)}>
|
||||
<HardDriveDownload className="mr-2 h-4 w-4" />
|
||||
{t('appstore.golden.button', 'Золотой шаблон')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSync} disabled={sync.isPending}>
|
||||
{sync.isPending
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -164,6 +174,8 @@ export default function AppStorePage() {
|
||||
{t('appstore.powered_by')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isAdmin && <GoldenTemplateDialog open={goldenOpen} onOpenChange={setGoldenOpen} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
172
frontend/src/features/appstore/GoldenTemplateDialog.tsx
Normal file
172
frontend/src/features/appstore/GoldenTemplateDialog.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, HardDriveDownload, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||
import { useBuildGoldenTemplate, useGoldenTemplateStatus } from '@/hooks/use-appstore';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: servers = [] } = useServers();
|
||||
|
||||
const [serverId, setServerId] = useState('');
|
||||
const [node, setNode] = useState('');
|
||||
const [templateStorage, setTemplateStorage] = useState('local');
|
||||
const [rootfsStorage, setRootfsStorage] = useState('local-lvm');
|
||||
const [force, setForce] = useState(false);
|
||||
|
||||
const serverIdNum = serverId ? Number(serverId) : 0;
|
||||
const { data: nodesData } = useNodes(serverIdNum || 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
|
||||
const build = useBuildGoldenTemplate();
|
||||
|
||||
// Пока операция выполняется — опрашиваем статус каждые 2с для прогресса.
|
||||
const { data: status } = useGoldenTemplateStatus(open ? 2000 : 0);
|
||||
const op = status?.last_operation ?? null;
|
||||
const running = op?.status === 'running';
|
||||
const lastStep = op?.steps_log?.length ? op.steps_log[op.steps_log.length - 1].step : null;
|
||||
|
||||
const configured = status?.configured_template ?? null;
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => !!serverIdNum && !!templateStorage && !!rootfsStorage && !running && !build.isPending,
|
||||
[serverIdNum, templateStorage, rootfsStorage, running, build.isPending],
|
||||
);
|
||||
|
||||
const handleBuild = () => {
|
||||
if (!serverIdNum) {
|
||||
toast.error(t('appstore.golden.pick_server', 'Выберите сервер Proxmox'));
|
||||
return;
|
||||
}
|
||||
build.mutate(
|
||||
{
|
||||
server_id: serverIdNum,
|
||||
node: node || undefined,
|
||||
template_storage: templateStorage,
|
||||
rootfs_storage: rootfsStorage,
|
||||
force,
|
||||
},
|
||||
{
|
||||
onSuccess: () => toast.success(t('appstore.golden.started', 'Сборка шаблона запущена')),
|
||||
onError: (e) => toast.error((e as Error).message || t('appstore.golden.failed', 'Не удалось запустить сборку')),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDriveDownload className="h-5 w-5 text-primary" />
|
||||
{t('appstore.golden.title', 'Золотой LXC-шаблон')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('appstore.golden.desc',
|
||||
'Автоматическая сборка golden-шаблона (Debian 12 + Docker + Compose) на выбранной ноде. Из него App Store клонирует контейнеры приложений.')}
|
||||
</p>
|
||||
|
||||
{/* Текущий шаблон */}
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('appstore.golden.current', 'Текущий шаблон')}: </span>
|
||||
{configured
|
||||
? <code className="break-all">{configured}</code>
|
||||
: <span className="text-amber-600">{t('appstore.golden.none', 'не настроен')}</span>}
|
||||
</div>
|
||||
|
||||
{/* Сервер */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('appstore.golden.server', 'Сервер Proxmox')}</Label>
|
||||
<Select value={serverId} onValueChange={(v) => { setServerId(v ?? ''); setNode(''); }}>
|
||||
<SelectTrigger><SelectValue placeholder={t('appstore.golden.pick_server', 'Выберите сервер')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Нода / хранилища */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('appstore.node', 'Нода')}</Label>
|
||||
<Select value={node || '__auto__'} onValueChange={(v) => setNode(v && v !== '__auto__' ? v : '')} disabled={!serverIdNum}>
|
||||
<SelectTrigger><SelectValue placeholder={t('wizard.auto_select', 'Авто')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto__">{t('wizard.auto_select', 'Авто')}</SelectItem>
|
||||
{nodes.map((n) => (
|
||||
<SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('appstore.golden.template_storage', 'Хранилище шаблона')}</Label>
|
||||
<Input value={templateStorage} onChange={(e) => setTemplateStorage(e.target.value)} placeholder="local" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('appstore.golden.rootfs_storage', 'Хранилище rootfs')}</Label>
|
||||
<Input value={rootfsStorage} onChange={(e) => setRootfsStorage(e.target.value)} placeholder="local-lvm" />
|
||||
</div>
|
||||
<label className="flex items-end gap-2 pb-2 text-sm">
|
||||
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} className="h-4 w-4" />
|
||||
{t('appstore.golden.force', 'Пересобрать')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Прогресс */}
|
||||
{op && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{running && <Loader2 className="h-4 w-4 animate-spin text-primary" />}
|
||||
{op.status === 'completed' && <CheckCircle2 className="h-4 w-4 text-green-600" />}
|
||||
{op.status === 'failed' && <XCircle className="h-4 w-4 text-destructive" />}
|
||||
<span className="truncate text-muted-foreground">
|
||||
{op.status === 'failed'
|
||||
? (op.error_text || t('appstore.golden.failed', 'Ошибка сборки'))
|
||||
: (lastStep || t('appstore.golden.working', 'Выполняется...'))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full transition-all ${op.status === 'failed' ? 'bg-destructive' : 'bg-primary'}`}
|
||||
style={{ width: `${op.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.close', 'Закрыть')}
|
||||
</Button>
|
||||
<Button onClick={handleBuild} disabled={!canSubmit}>
|
||||
{(build.isPending || running)
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <HardDriveDownload className="mr-2 h-4 w-4" />}
|
||||
{t('appstore.golden.build', 'Собрать шаблон')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,41 @@ export function useSyncCatalog() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Золотой шаблон (Workstream B) ────────────────────────────────────────────
|
||||
|
||||
export interface GoldenTemplateStatus {
|
||||
configured_template: string | null;
|
||||
env_template: string | null;
|
||||
saved_template: string | null;
|
||||
last_operation: AppOperation | null;
|
||||
}
|
||||
|
||||
export interface GoldenBuildArgs {
|
||||
server_id: number;
|
||||
node?: string;
|
||||
template_storage?: string;
|
||||
rootfs_storage?: string;
|
||||
bridge?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export function useGoldenTemplateStatus(pollMs?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['appstore-golden-status'],
|
||||
queryFn: () => apiClient.get<GoldenTemplateStatus>('/api/appstore/golden-template/status'),
|
||||
refetchInterval: pollMs && pollMs > 0 ? pollMs : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBuildGoldenTemplate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: GoldenBuildArgs) =>
|
||||
apiClient.post<{ success: boolean; operation_id: number }>('/api/appstore/golden-template', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['appstore-golden-status'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Установленные ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useInstalledApps() {
|
||||
|
||||
@@ -30,6 +30,22 @@
|
||||
"runtipi": "Runtipi",
|
||||
"umbrel": "Umbrel"
|
||||
},
|
||||
"golden": {
|
||||
"button": "Golden template",
|
||||
"title": "Golden LXC template",
|
||||
"desc": "Automatically build the golden template (Debian 12 + Docker + Compose) on the selected node. App Store clones app containers from it.",
|
||||
"current": "Current template",
|
||||
"none": "not configured",
|
||||
"server": "Proxmox server",
|
||||
"pick_server": "Select a server",
|
||||
"template_storage": "Template storage",
|
||||
"rootfs_storage": "Rootfs storage",
|
||||
"force": "Rebuild",
|
||||
"build": "Build template",
|
||||
"started": "Template build started",
|
||||
"failed": "Build failed",
|
||||
"working": "Working..."
|
||||
},
|
||||
"refresh_catalog": "Refresh catalog",
|
||||
"last_synced": "Last synced",
|
||||
"never": "never",
|
||||
|
||||
@@ -30,6 +30,22 @@
|
||||
"runtipi": "Runtipi",
|
||||
"umbrel": "Umbrel"
|
||||
},
|
||||
"golden": {
|
||||
"button": "Золотой шаблон",
|
||||
"title": "Золотой LXC-шаблон",
|
||||
"desc": "Автоматическая сборка golden-шаблона (Debian 12 + Docker + Compose) на выбранной ноде. Из него App Store клонирует контейнеры приложений.",
|
||||
"current": "Текущий шаблон",
|
||||
"none": "не настроен",
|
||||
"server": "Сервер Proxmox",
|
||||
"pick_server": "Выберите сервер",
|
||||
"template_storage": "Хранилище шаблона",
|
||||
"rootfs_storage": "Хранилище rootfs",
|
||||
"force": "Пересобрать",
|
||||
"build": "Собрать шаблон",
|
||||
"started": "Сборка шаблона запущена",
|
||||
"failed": "Ошибка сборки",
|
||||
"working": "Выполняется..."
|
||||
},
|
||||
"refresh_catalog": "Обновить каталог",
|
||||
"last_synced": "Обновлён",
|
||||
"never": "никогда",
|
||||
|
||||
Reference in New Issue
Block a user