feat(appstore): автосборка золотого LXC-шаблона из UI (Workstream B)
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s

Автоматизирует ручную процедуру 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:
markmorado
2026-07-09 01:53:47 +05:00
parent aebb726231
commit 629357a0ee
9 changed files with 617 additions and 6 deletions

View File

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

View 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()

View File

@@ -168,6 +168,7 @@ APP_OPERATION_LABELS = {
"start": "Запуск приложения",
"stop": "Остановка приложения",
"restart": "Перезапуск приложения",
"golden_template": "Подготовка золотого шаблона",
}

View File

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

View File

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

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

View File

@@ -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() {

View File

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

View File

@@ -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": "никогда",