fix(appstore): data files, надёжность установки/удаления и сворачиваемые диалоги
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 8s

Доставка файлов приложения из каталога (data_files) до compose up,
APP_PASSWORD/default_credentials для Umbrel, сворачивание диалогов
долгих операций (MinimizableDialog), фильтрация pull-шума в ошибках.

Исправления багов:
- teardown: отсутствующий в Proxmox CT считается удалённым — orphaned-приложение
  теперь можно удалить из БД
- install: сдвиг VMID мимо занятых записей (nextid не резервирует номер,
  retry мог подхватить чужой контейнер)
- retry: параметры установки сохраняются в installed_apps.install_params
  (cores/memory/disk/storage/bridge/ip_config/port_explicit, миграция JSONB)
  вместо жёстких дефолтов
- reconcile: недоступный сервер больше не помечает все приложения orphaned
- update: реально опубликованный порт фиксируется в БД (url/health)
- .env: значения с '#' кавычатся (dotenv-парсер обрезал их как inline-комментарий)
- порт 80, выбранный явно в мастере, публикуется как есть
- useCatalog: available_only включён в queryKey (коллизия кэша)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
markmorado
2026-07-17 12:52:14 +05:00
parent f22b24d86a
commit 4621ffe88a
14 changed files with 654 additions and 72 deletions

View File

@@ -135,7 +135,12 @@ def app_credentials(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("app:view")),
):
"""Секреты из формы (password/random) — расшифрованные. Только владелец/админ."""
"""Секреты из формы (password/random) — расшифрованные. Только владелец/админ.
Дополнительно: учётные данные по умолчанию из манифеста источника
(umbrel-app.yml → default_credentials) и сгенерированный APP_PASSWORD —
для многих приложений это единственный способ узнать логин/пароль админа.
"""
ia = _get_owned(db, app_id, current_user)
env = ia.env_dict()
catalog = db.query(CatalogApp).filter(CatalogApp.app_id == ia.app_id).first()
@@ -143,7 +148,17 @@ def app_credentials(
for f in (catalog.form_fields if catalog else []) or []:
if (f.get("type") or "").lower() in ("password", "random") and f.get("env_variable"):
secret_keys.add(f["env_variable"])
return {"credentials": {k: v for k, v in env.items() if k in secret_keys}}
creds = {k: v for k, v in env.items() if k in secret_keys}
dc = (catalog.default_credentials if catalog else None) or {}
if dc.get("username"):
creds.setdefault("USERNAME", dc["username"])
default_pwd = env.get("APP_PASSWORD") if dc.get("deterministic") else dc.get("password")
if default_pwd:
creds.setdefault("PASSWORD", default_pwd)
if env.get("APP_PASSWORD"):
creds.setdefault("APP_PASSWORD", env["APP_PASSWORD"])
return {"credentials": creds}
@router.post("/api/appstore/apps/{app_id}/retry")

View File

@@ -19,6 +19,7 @@ from __future__ import annotations
import base64
import re
import secrets
import time
from dataclasses import dataclass, field
from typing import Callable, Dict, Optional
@@ -54,6 +55,10 @@ class InstallSpec:
name: str # hostname / имя приложения
compose_yaml: str # содержимое docker-compose.yml
port: int = 3001 # порт для health-check
# Порт выбран пользователем явно (форма мастера), а не взят из каталога.
# Влияет на нормализацию compose: явный порт публикуется как есть (в т.ч.
# 80), а каталожные 0/80 считаются «не задано» → публикуем внутренний.
port_explicit: bool = False
env_vars: Dict[str, str] = field(default_factory=dict)
node: Optional[str] = None # если None — выбирается автоматически
vmid: Optional[int] = None # если задан — использовать его (идемпотентный повтор)
@@ -76,6 +81,11 @@ class InstallSpec:
# без этого CT наследует резолвер ноды, который может быть недоступен из сети
# контейнера (напр. Tailscale-адрес), и `docker compose up` падает на pull образов.
nameserver: Optional[str] = None
# Вспомогательные файлы приложения из репозитория каталога
# ({relpath: {"b64": ..., "exec": bool}}, см. CatalogApp.data_files) —
# доставляются в ${APP_DATA_DIR}/<relpath> ДО compose up, иначе bind-mount'ы
# на них получают пустышки и приложение падает (пустой entrypoint.sh/конфиг).
data_files: Dict[str, dict] = field(default_factory=dict)
@dataclass
@@ -114,6 +124,22 @@ _DOCKER_TIMEOUT = 600
_TEARDOWN_WAIT_TIMEOUT = 1800
_LAYER_PROGRESS_RE = re.compile(
r"^\s*[0-9a-f]{12}\s+(Pulling|Waiting|Downloading|Verifying Checksum|"
r"Download complete|Extracting|Pull complete|Already exists)\b"
)
def _strip_pull_noise(output: str) -> str:
"""Убрать построчный progress-шум `docker compose pull/up` (на каждый слой
образа своя строка Downloading/Extracting и т.п., перерисовывающаяся много
раз). На тяжёлых многослойных образах этот шум может занять весь хвост
вывода, вытеснив из среза саму причину падения (см. _run).
"""
lines = [ln for ln in output.splitlines() if not _LAYER_PROGRESS_RE.match(ln)]
return "\n".join(lines)
def _run(ssh: SSHClient, cmd: str, *, what: str, timeout: int = _CMD_TIMEOUT) -> str:
"""Выполнить команду на ноде, вернуть stdout. Бросить PocError при exit!=0.
@@ -127,8 +153,14 @@ def _run(ssh: SSHClient, cmd: str, *, what: str, timeout: int = _CMD_TIMEOUT) ->
# Хвост, а не голова: для многословных команд (docker compose pull/up)
# реальная причина падения — последние строки, а не progress-шум
# "Pulling/Downloading" в начале. Раньше срез [:2000] обрезал именно
# причину, оставляя в UI один прогресс-бар без объяснения.
raise PocError(f"{what}: exit={exit_code}: {(output or '').strip()[-4000:]}")
# причину, оставляя в UI один прогресс-бар без объяснения. Но и хвост
# может целиком утонуть в построчном progress-шуме на тяжёлых образах
# (много слоёв) — поэтому сначала вычищаем его, если после этого
# что-то остаётся.
text = (output or "").strip()
filtered = _strip_pull_noise(text).strip()
tail = filtered or text
raise PocError(f"{what}: exit={exit_code}: {tail[-4000:]}")
return output or ""
@@ -274,8 +306,27 @@ def _resolve_main_and_port(doc: dict, services: Dict, source: str
return main, internal, False
def _published_host_port(entry, host_port: Optional[int]) -> Optional[int]:
"""Хост-порт из записи `ports:` compose.
Понимает short-syntax ('8080:80', '${APP_PORT:-3001}:3001',
'127.0.0.1:8080:80') и long-syntax ({published: ..., target: ...}).
Переменную APP_PORT подставляем значением host_port — именно его кладём
в .env, т.е. так её раскроет и docker compose. Раньше '${APP_PORT...'
парсился как не-число → health-check шёл на внутренний порт, который
наружу не опубликован, и установка ложно помечалась unhealthy.
"""
env = {"APP_PORT": str(host_port)} if host_port else {}
if isinstance(entry, dict):
pub = entry.get("published")
return _to_int(_subst_env(str(pub), env)) if pub is not None else None
parts = _subst_env(str(entry), env).split(":")
return _to_int(parts[-2]) if len(parts) >= 2 else None
def _normalize_compose(compose_yaml: str, host_port: Optional[int] = None,
*, source: str = "runtipi") -> tuple[str, Optional[int]]:
*, source: str = "runtipi",
port_explicit: bool = False) -> tuple[str, Optional[int]]:
"""Привести compose к самодостаточному виду для standalone-запуска в LXC.
Делает то, что в родном рантайме (Runtipi/Umbrel) выполняет их оркестратор:
@@ -346,21 +397,22 @@ def _normalize_compose(compose_yaml: str, host_port: Optional[int] = None,
# Уважаем уже объявленную публикацию (не переопределяем).
if not svc.get("ports"):
# host_port из каталога, если задан и не дефолтные 80/0; иначе
# публикуем на том же номере, что слушает контейнер.
pub = host_port if (host_port and host_port not in (0, 80)) else internal
# публикуем на том же номере, что слушает контейнер. Порт,
# явно выбранный пользователем в мастере, уважаем всегда
# (в т.ч. 80 — для каталога это «не задано», для юзера нет).
pub = host_port if (host_port and (port_explicit or host_port not in (0, 80))) else internal
svc["ports"] = [f"{pub}:{internal}"]
published_port = pub
changed = True
else:
ports = svc.get("ports")
if isinstance(ports, list) and ports:
first = str(ports[0])
published_port = _to_int(first.split(":")[0]) or internal
published_port = _published_host_port(ports[0], host_port) or internal
elif svc.get("ports"):
# Внутренний порт неизвестен, но публикация уже есть в compose.
ports = svc.get("ports")
if isinstance(ports, list) and ports:
published_port = _to_int(str(ports[0]).split(":")[0])
published_port = _published_host_port(ports[0], host_port)
if not changed:
return compose_yaml, published_port
@@ -385,17 +437,32 @@ def _platform_env(env: Dict[str, str], ip: str, source: str) -> Dict[str, str]:
env.setdefault("DEVICE_DOMAIN_NAME", f"{ip}.local")
env.setdefault("DEVICE_HOSTNAME", "umbrel")
env.setdefault("APP_SEED", "pvemanager")
# Umbrel генерирует APP_PASSWORD сам (deterministic app password) —
# compose-файлы ссылаются на неё для паролей/ключей. Обычно её задаёт
# build_env движка (чтобы значение сохранилось и показалось
# пользователю); здесь — страховка для standalone-запуска пайплайна.
env.setdefault("APP_PASSWORD", secrets.token_hex(16))
else:
env.setdefault("LOCAL_DOMAIN", "tipi.local") # стандартная переменная Runtipi
return env
_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
_VAR_RE = re.compile(
r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)"
)
def _subst_env(value: str, env: Dict[str, str]) -> str:
"""Мини-версия подстановки переменных docker compose (${VAR}/$VAR → env[VAR] или '')."""
return _VAR_RE.sub(lambda m: env.get(m.group(1) or m.group(2), ""), value)
"""Мини-версия подстановки переменных docker compose:
${VAR}, ${VAR-def}, ${VAR:-def}, $VAR → env[VAR] | default | ''.
"""
def _rep(m: re.Match) -> str:
var = m.group(1) or m.group(3)
val = env.get(var)
if val:
return val
return m.group(2) or ""
return _VAR_RE.sub(_rep, value)
def _bind_mount_hosts(compose_yaml: str, app_dir: str, env: Dict[str, str]) -> list:
@@ -429,12 +496,13 @@ def _bind_mount_hosts(compose_yaml: str, app_dir: str, env: Dict[str, str]) -> l
for v in vols:
if not isinstance(v, str) or ":" not in v:
continue
source = v.split(":", 1)[0]
# Именованный volume ("dbdata:/var/lib/mysql") — без '/', '.', '~', '$'
# в начале; управляется Docker'ом, пропускаем.
if not (source.startswith(("/", ".", "~", "$"))):
if not (v.startswith(("/", ".", "~", "$"))):
continue
resolved = _subst_env(source, env)
# Подстановка ДО split(':'): внутри ${VAR:-default} есть ':',
# который иначе рвёт строку не на границе source:target.
resolved = _subst_env(v, env).split(":", 1)[0]
if not resolved:
continue
if not resolved.startswith("/"):
@@ -450,8 +518,13 @@ def _is_file_mount(source: str) -> bool:
создаёт директорию — для файловых bind-mount'ов (напр. AnythingLLM монтирует
`.env` как файл) это ломает `docker compose up` ошибкой "not a directory:
Are you trying to mount a directory onto a file?".
Скрытые имена (`.config`, `.n8n`, `.ssh`) — почти всегда ДИРЕКТОРИИ:
ведущая точка не считается расширением. Файлы среди них — только `.env*`.
"""
name = source.rsplit("/", 1)[-1]
if name.startswith("."):
return name.lower().startswith(".env")
return "." in name
@@ -474,28 +547,117 @@ def _prepare_bind_mounts(ssh: SSHClient, vmid: int, compose_yaml: str,
for f in files:
parent = f.rsplit("/", 1)[0] if "/" in f else "."
qf, qp = _shq(f), _shq(parent)
# rm -rf — самовосстановление после старого бага: если source уже был
# ошибочно создан как директория в прошлой попытке (даже непустая —
# Docker ничего в неё не писал, падал раньше на самом mount), сносим
# её перед touch; иначе Docker потом не может смонтировать файл поверх
# rmdir — самовосстановление после старого бага: если source уже был
# ошибочно создан Docker'ом как ПУСТАЯ директория в прошлой попытке,
# убираем её перед touch; иначе Docker не может смонтировать файл поверх
# директории ("not a directory: ... mount a directory onto a file").
# Непустую директорию не трогаем (там могут быть данные приложения —
# ошибка эвристики не должна приводить к потере данных).
# chmod 777, а не 666: доставленные из каталога скрипты (entrypoint.sh
# и т.п.) должны сохранить/получить exec-бит, иначе контейнер падает
# с "permission denied" на entrypoint.
cmds.append(
f"mkdir -p {qp}; if [ -d {qf} ]; then rm -rf {qf}; fi; "
f"[ -e {qf} ] || touch {qf}; chmod 666 {qf}"
f"mkdir -p {qp}; if [ -d {qf} ]; then rmdir {qf} 2>/dev/null || true; fi; "
f"[ -e {qf} ] || touch {qf}; [ -d {qf} ] || chmod 777 {qf}"
)
_run(ssh, _pct(vmid, " && ".join(cmds)), what="prepare bind-mount dirs")
def _push_file(ssh: SSHClient, vmid: int, content: str, dest: str) -> None:
"""Доставить файл в контейнер без shell-инъекций: base64 → файл на ноде → pct push."""
b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
def _push_file(ssh: SSHClient, vmid: int, content, dest: str, *,
perms: Optional[str] = None) -> None:
"""Доставить файл в контейнер без shell-инъекций: base64 → файл на ноде → pct push.
content — str (utf-8) или bytes (бинарные файлы каталога).
"""
raw = content.encode("utf-8") if isinstance(content, str) else content
b64 = base64.b64encode(raw).decode("ascii")
tmp = f"/tmp/appstore_{vmid}_{abs(hash(dest)) % 100000}"
# b64 состоит из [A-Za-z0-9+/=], безопасно внутри одинарных кавычек.
_run(ssh, f"printf '%s' '{b64}' | base64 -d > {tmp}", what=f"write {dest} on node")
_run(ssh, f"pct push {vmid} {tmp} {dest}", what=f"pct push {dest}")
perms_arg = f" --perms {perms}" if perms else ""
_run(ssh, f"pct push {vmid} {tmp} {dest}{perms_arg}", what=f"pct push {dest}")
_run(ssh, f"rm -f {tmp}", what="cleanup temp")
# Имя relpath data-файла: только безопасные символы (dest не квотируется в
# `pct push`), без '..' и ведущих '/' — защита от path traversal и инъекций.
_DATA_RELPATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/\-@]*$")
# Плейсхолдер Runtipi-шаблона: {{VAR}} / {{ VAR }} в файлах data/*.template.
_TPL_VAR_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
def _render_template(blob: bytes, env: Dict[str, str]) -> bytes:
"""Рендер Runtipi-шаблона: {{VAR}} → env[VAR] (отсутствующие → '')."""
text = blob.decode("utf-8", "replace")
text = _TPL_VAR_RE.sub(lambda m: str(env.get(m.group(1), "")), text)
return text.encode("utf-8")
def _push_data_files(ssh: SSHClient, vmid: int, data_files: Dict[str, dict],
env: Dict[str, str]) -> None:
"""Доставить файлы приложения из каталога в ${APP_DATA_DIR}/<relpath>.
Compose bind-mount'ит их (напр. ${APP_DATA_DIR}/data/nginx.conf,
${APP_DATA_DIR}/entrypoint.sh) — без доставки на их месте оказались бы
пустые файлы-заглушки, и приложение падает на старте. Exec-бит
сохраняется (скрипты); прочим файлам даём 666 (владелец внутри
контейнера не разграничивается — см. _bind_mount_hosts).
Файлы `*.template` (конвенция Runtipi) рендерятся ({{VAR}} → env) и
кладутся под именем без суффикса — именно его монтирует compose.
"""
safe: Dict[str, bytes] = {}
execs: Dict[str, bool] = {}
for relpath, meta in (data_files or {}).items():
if not isinstance(meta, dict) or not meta.get("b64"):
continue
if not _DATA_RELPATH_RE.match(relpath) or ".." in relpath:
logger.warning(f"[appstore-poc] skip data file with unsafe path: {relpath!r}")
continue
try:
blob = base64.b64decode(meta["b64"])
except Exception:
logger.warning(f"[appstore-poc] skip data file {relpath}: битый base64")
continue
if relpath.endswith(".template"):
relpath = relpath[: -len(".template")]
blob = _render_template(blob, env)
safe[relpath] = blob
execs[relpath] = bool(meta.get("exec"))
if not safe:
return
# Заранее создаём все родительские директории одним pct exec.
dirs = sorted({f"{APP_DATA_DIR}/{rp}".rsplit("/", 1)[0] for rp in safe})
_run(ssh, _pct(vmid, "mkdir -p " + " ".join(_shq(d) for d in dirs)),
what="mkdir data-file dirs")
for relpath, blob in safe.items():
_push_file(ssh, vmid, blob, f"{APP_DATA_DIR}/{relpath}",
perms="0777" if execs[relpath] else "0666")
def _env_file_value(v) -> str:
"""Значение для строки .env: одна строка на переменную + кавычение.
dotenv-парсер docker compose считает ` #` в незакавыченном значении началом
inline-комментария и обрезает хвост — пароль вида "pa#ss" молча превращался
в "pa". Такие значения (а также с ведущими/хвостовыми пробелами) оборачиваем
в двойные кавычки. Остальные оставляем как есть, чтобы не менять поведение
подстановки ($VAR) для существующих приложений.
"""
s = str(v).replace("\r", " ").replace("\n", " ")
if "#" in s or s != s.strip():
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
return s
def _env_file_content(env: Dict[str, str]) -> str:
return "".join(f"{k}={_env_file_value(v)}\n" for k, v in env.items())
def _wait_ip(ssh: SSHClient, vmid: int, timeout: int) -> Optional[str]:
deadline = time.time() + timeout
while time.time() < deadline:
@@ -608,17 +770,18 @@ def poc_install(db: Session, spec: InstallSpec, on_step: StepCb = _noop) -> Inst
on_step("Доставка docker-compose.yml и .env (pct push)...", 75)
normalized, pub_port = _normalize_compose(spec.compose_yaml, spec.port,
source=spec.source)
source=spec.source,
port_explicit=spec.port_explicit)
if pub_port:
# Порт, реально опубликованный на хосте LXC → на нём и проверяем/URL.
spec.port = pub_port
_push_file(ssh, vmid, normalized, f"{APP_DIR}/docker-compose.yml")
# IP-зависимые платформенные переменные (ТЗ 5.3) заполняем здесь — IP уже известен.
env = _platform_env(dict(spec.env_vars), ip, spec.source)
# значения .env: убираем переводы строк (одна строка на переменную)
env_content = "".join(f"{k}={str(v).replace(chr(10), ' ').replace(chr(13), ' ')}\n"
for k, v in env.items())
_push_file(ssh, vmid, env_content, f"{APP_DIR}/.env")
_push_file(ssh, vmid, _env_file_content(env), f"{APP_DIR}/.env")
# Файлы приложения из репозитория каталога (конфиги/скрипты) — ДО
# подготовки bind-mount'ов, чтобы на их местах не появились пустышки.
_push_data_files(ssh, vmid, spec.data_files, env)
# Bind-mount директории заранее и с правами 777 — иначе Docker создаст
# их сам от root при первом `up`, и процессы в контейнере (напр.
# mariadb от uid mysql) получат Permission denied на запись.
@@ -661,11 +824,15 @@ def _health_check(ip: str, port: int, timeout: int) -> bool:
def poc_update(server, client, vmid: int, node: str, compose_yaml: str,
env_vars: Dict[str, str], port: int, ip: str,
on_step: StepCb = _noop, health_timeout: int = 300,
source: str = "runtipi") -> str:
source: str = "runtipi",
data_files: Optional[Dict[str, dict]] = None,
port_explicit: bool = False) -> tuple[str, int]:
"""Обновление приложения в существующем контейнере (M4): доставка нового
docker-compose.yml → docker compose pull && up -d → health-check.
Возвращает 'running' (health OK) или 'unhealthy'.
Возвращает ('running' | 'unhealthy', эффективный_порт). Порт возвращаем,
потому что нормализация compose может опубликовать порт, отличный от
сохранённого в БД (ia.port), — иначе URL/health в БД укажут не туда.
"""
ssh = _node_ssh(server, client)
if not ssh.connect():
@@ -679,14 +846,14 @@ def poc_update(server, client, vmid: int, node: str, compose_yaml: str,
f"docker network create {TIPI_NETWORK}"),
what="ensure docker network",
)
normalized, pub_port = _normalize_compose(compose_yaml, port, source=source)
normalized, pub_port = _normalize_compose(compose_yaml, port, source=source,
port_explicit=port_explicit)
if pub_port:
port = pub_port
_push_file(ssh, vmid, normalized, f"{APP_DIR}/docker-compose.yml")
env = _platform_env(dict(env_vars), ip, source)
env_content = "".join(f"{k}={str(v).replace(chr(10), ' ').replace(chr(13), ' ')}\n"
for k, v in env.items())
_push_file(ssh, vmid, env_content, f"{APP_DIR}/.env")
_push_file(ssh, vmid, _env_file_content(env), f"{APP_DIR}/.env")
_push_data_files(ssh, vmid, data_files or {}, env)
_prepare_bind_mounts(ssh, vmid, normalized, APP_DIR, env)
on_step("docker compose pull && up -d...", 65)
@@ -699,7 +866,7 @@ def poc_update(server, client, vmid: int, node: str, compose_yaml: str,
)
on_step(f"Health-check http://{ip}:{port} ...", 88)
return "running" if _health_check(ip, port, health_timeout) else "unhealthy"
return ("running" if _health_check(ip, port, health_timeout) else "unhealthy"), port
finally:
ssh.close()
@@ -722,7 +889,17 @@ def poc_teardown(db: Session, server_id: int, vmid: int, node: Optional[str] = N
logger.warning(f"stop_container: {e}")
on_step(f"Удаление LXC {vmid}...", 70)
upid = client.delete_container(node, vmid, force=True)
try:
upid = client.delete_container(node, vmid, force=True)
except Exception as e:
# CT уже отсутствует (удалён вручную в Proxmox / статус orphaned) —
# считаем teardown успешным, иначе запись в БД нельзя удалить никогда:
# каждый повтор удаления падал бы на этом же месте.
msg = str(e).lower()
if "does not exist" in msg or "no such" in msg:
on_step(f"LXC {vmid} уже отсутствует в Proxmox — пропускаем", 100)
return
raise
if upid:
# Ждём реального завершения задачи, а не просто отдаём таймаут наверх:
# иначе caller решит, что ресурсы освобождены, пока нода ещё занята

View File

@@ -45,6 +45,16 @@ class CatalogApp(Base):
# массив объектов form_fields из config.json (см. ТЗ 6.2)
form_fields = Column(JSON, nullable=False, default=list)
compose_yaml = Column(Text, nullable=True)
# Вспомогательные файлы приложения из репозитория каталога, которые compose
# bind-mount'ит из ${APP_DATA_DIR} (runtipi: apps/<id>/data/**, umbrel: файлы
# папки приложения). Без них ~60% приложений получают пустые конфиги/скрипты
# и падают на старте. Формат: {relpath: {"b64": <содержимое>, "exec": bool}}.
data_files = Column(JSON, nullable=True)
# Учётные данные по умолчанию из манифеста источника (umbrel-app.yml:
# defaultUsername/defaultPassword/deterministicPassword). deterministic=true →
# реальный пароль — это сгенерированный при установке APP_PASSWORD.
# Формат: {"username": str|None, "password": str|None, "deterministic": bool}.
default_credentials = Column(JSON, nullable=True)
architectures = Column(JSON, nullable=False, default=list)
available = Column(Boolean, nullable=False, default=True, index=True)
@@ -118,6 +128,10 @@ class InstalledApp(Base):
status = Column(String(20), nullable=False, default="installing", index=True)
update_available = Column(Boolean, nullable=False, default=False)
last_snapshot = Column(String(200), nullable=True)
# Параметры создания CT ({cores, memory, disk, storage, bridge, ip_config}) —
# чтобы повтор установки пересоздал контейнер с исходными настройками,
# а не с дефолтами (NULL у записей, созданных до появления колонки).
install_params = Column(JSON, nullable=True)
# версия/tipi_version, зафиксированные в last_snapshot — для отката версии в БД (M4)
snapshot_version = Column(String(50), nullable=True)
snapshot_tipi_version = Column(Integer, nullable=True)

View File

@@ -93,6 +93,10 @@ class LxcMixin:
"""
Сменить пароль пользователя в LXC через `pct exec` (chpasswd).
Контейнер должен быть запущен.
REST-эндпоинта /nodes/{node}/lxc/{vmid}/exec в Proxmox нет (501),
поэтому выполняем pct exec по SSH (root@node) — тот же паттерн,
что в appstore/pipeline.py и _delete_cluster_node_via_ssh.
"""
if not self.proxmox:
return {'success': False, 'error': 'Proxmox connection not initialized'}
@@ -100,16 +104,34 @@ class LxcMixin:
result = self.proxmox.nodes(node).lxc(vmid).status.current.get()
if result.get('status') != 'running':
return {'success': False, 'error': 'Container is not running'}
# Используем printf + chpasswd; shlex.quote защищает от shell-injection
# Proxmox exec API ожидает отдельные поля: command + args[N]
except Exception as e:
logger.error(f"Не удалось получить статус LXC {vmid}: {e}")
return {'success': False, 'error': str(e)}
from app.ssh_client import SSHClient
# self.host может содержать порт API ("1.2.3.4:8006"); SSH нужен голый хост.
ssh_host = self.host.split(':')[0] if self.host else self.host
ssh = SSHClient(
hostname=ssh_host,
username='root',
password=getattr(self, '_password', None),
)
try:
if not ssh.connect():
return {'success': False, 'error': 'SSH connection to node failed'}
# printf + chpasswd; shlex.quote защищает от shell-injection
entry = shlex.quote(f'{username}:{password}')
cmd_str = f"printf '%s\\n' {entry} | chpasswd"
data = {'command': 'bash', 'args[0]': '-c', 'args[1]': cmd_str}
res = self.proxmox.nodes(node).lxc(vmid).exec.post(**data)
return {'success': True, 'result': res}
inner = f"printf '%s\\n' {entry} | chpasswd"
cmd = f"pct exec {vmid} -- bash -c {shlex.quote(inner)}"
output, exit_code = ssh.execute(cmd, return_exit_code=True)
if exit_code == 0:
return {'success': True}
return {'success': False, 'error': output or f'chpasswd failed (exit {exit_code})'}
except Exception as e:
logger.error(f"Не удалось сменить пароль в LXC {vmid}: {e}")
return {'success': False, 'error': str(e)}
finally:
ssh.close()
def set_container_notes(self, node: str, vmid: int, description: str) -> bool:
"""Установить description (заметки) у LXC."""

View File

@@ -12,6 +12,7 @@ Catalog Service (M1) — конвертер runtipi/runtipi-appstore → таб
from __future__ import annotations
import base64
import json
import os
import tarfile
@@ -35,6 +36,12 @@ from ..models import CatalogApp, InstalledApp
# Расширения логотипов в порядке предпочтения
_LOGO_NAMES = ("logo.jpg", "logo.png", "logo.jpeg", "logo.webp", "logo.svg")
# Лимиты на вспомогательные файлы приложения (data_files): защищаемся от
# гигантских бинарей в репозитории каталога — они не нужны для bind-mount'ов
# конфигов/скриптов, а раздули бы БД.
_DATA_FILE_MAX = 512 * 1024 # на один файл
_DATA_TOTAL_MAX = 4 * 1024 * 1024 # суммарно на приложение
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
@@ -58,6 +65,27 @@ class AppRaw:
self.description_md: Optional[str] = None
self.logo_bytes: Optional[bytes] = None
self.logo_ext: Optional[str] = None
# Файлы приложения, которые compose монтирует из ${APP_DATA_DIR}:
# {relpath: {"b64": <base64>, "exec": bool}}. Без них у ~60% приложений
# bind-mount'ы указывают на несуществующие конфиги/скрипты.
self.data_files: Dict[str, dict] = {}
self._data_total = 0
def add_data_file(self, relpath: str, data: bytes, mode: int) -> None:
"""Добавить вспомогательный файл с учётом лимитов размера."""
if len(data) > _DATA_FILE_MAX:
logger.info(f"[appstore] skip data file {self.app_id}/{relpath}: "
f"{len(data)} bytes > {_DATA_FILE_MAX}")
return
if self._data_total + len(data) > _DATA_TOTAL_MAX:
logger.info(f"[appstore] skip data file {self.app_id}/{relpath}: "
f"превышен общий лимит {_DATA_TOTAL_MAX}")
return
self.data_files[relpath] = {
"b64": base64.b64encode(data).decode("ascii"),
"exec": bool(mode & 0o100),
}
self._data_total += len(data)
# ── Провайдер источника (изоляция, риск п.13) ─────────────────────────────────
@@ -130,6 +158,12 @@ class RuntipiCatalogProvider(CatalogProvider):
elif len(rel) == 2 and rel[0] == "metadata" and fname in _LOGO_NAMES:
raw.logo_bytes = self._read(tar, member)
raw.logo_ext = os.path.splitext(fname)[1] or ".jpg"
elif len(rel) >= 2 and rel[0] == "data":
# apps/<id>/data/** — файлы, которые Runtipi копирует в
# app-data и на которые compose ссылается как
# ${APP_DATA_DIR}/data/<...> (конфиги, скрипты).
raw.add_data_file("/".join(rel), self._read(tar, member),
member.mode)
except Exception as e:
logger.warning(f"[appstore] parse {app_id}/{'/'.join(rel)}: {e}")
finally:
@@ -165,25 +199,33 @@ class UmbrelCatalogProvider(CatalogProvider):
for member in tar:
if not member.isfile():
continue
# путь вида "<repo>-<ref>/<app-id>/<file>"
# путь вида "<repo>-<ref>/<app-id>/<...>"
parts = member.name.split("/")
if len(parts) != 3:
continue # интересуют только файлы в корне папки приложения
if len(parts) < 3:
continue
raw_id = parts[1]
if not raw_id or raw_id.startswith("."):
continue # .github, .gitignore и т.п.
app_id = self.PREFIX + raw_id
fname = parts[-1].lower()
rel = "/".join(parts[2:]) # путь внутри папки приложения
raw = apps.setdefault(app_id, AppRaw(app_id, source=self.source))
try:
if fname == "umbrel-app.yml":
if rel == "umbrel-app.yml":
raw.config = self._map_manifest(
raw_id, yaml.safe_load(self._read(tar, member)))
elif fname == "docker-compose.yml":
elif rel == "docker-compose.yml":
raw.compose = self._read(tar, member).decode("utf-8", "replace")
elif fname in _LOGO_NAMES:
elif len(parts) == 3 and fname in _LOGO_NAMES:
raw.logo_bytes = self._read(tar, member)
raw.logo_ext = os.path.splitext(fname)[1] or ".jpg"
elif len(parts) == 3 and fname.startswith(("icon.", "gallery")):
pass # ассеты витрины — не файлы приложения
else:
# Остальные файлы (включая вложенные, напр. data/**,
# entrypoint.sh) Umbrel копирует в app-data приложения,
# а compose монтирует их как ${APP_DATA_DIR}/<relpath>.
raw.add_data_file(rel, self._read(tar, member), member.mode)
except Exception as e:
logger.warning(f"[appstore] parse umbrel {app_id}/{fname}: {e}")
finally:
@@ -244,6 +286,17 @@ class UmbrelCatalogProvider(CatalogProvider):
cat = meta.get("category")
categories = [str(cat)] if cat else []
port = meta.get("port")
# Учётные данные по умолчанию: без них пользователю после установки
# неоткуда узнать логин/пароль приложения (Umbrel показывает их в своём
# UI). deterministicPassword: true — пароль генерирует рантайм и
# передаёт через APP_PASSWORD (у нас — сгенерированный при установке).
default_creds = None
du = str(meta.get("defaultUsername") or "").strip()
dp = str(meta.get("defaultPassword") or "").strip()
deterministic = bool(meta.get("deterministicPassword"))
if du or dp or deterministic:
default_creds = {"username": du or None, "password": dp or None,
"deterministic": deterministic}
return {
"name": meta.get("name") or raw_id,
"version": str(meta.get("version")) if meta.get("version") is not None else None,
@@ -261,6 +314,7 @@ class UmbrelCatalogProvider(CatalogProvider):
"dynamic_config": False,
"source": meta.get("repo") or meta.get("website"),
"author": meta.get("developer") or meta.get("submitter"),
"default_credentials": default_creds,
}
@@ -316,6 +370,8 @@ def _build_fields(app_id: str, raw: AppRaw) -> Optional[dict]:
"port": cfg.get("port") if isinstance(cfg.get("port"), int) else None,
"form_fields": cfg.get("form_fields") or [],
"compose_yaml": raw.compose,
"data_files": raw.data_files or None,
"default_credentials": cfg.get("default_credentials"),
"architectures": archs,
"available": available,
"unavailable_reason": reason,

View File

@@ -113,6 +113,15 @@ def build_env(app: CatalogApp, form_answers: dict) -> tuple[dict, dict]:
}
secrets_shown: dict = {}
# Umbrel: рантайм генерирует APP_PASSWORD (app password) сам, а compose
# ссылается на неё для паролей/ключей (ENCRYPTION_KEY, JWT_SECRET и т.п.).
# Без неё docker compose подставит пустую строку и приложение упадёт или
# останется без пароля. Генерируем здесь, чтобы значение сохранилось в
# env_encrypted и показалось пользователю один раз.
if (app.source or "runtipi") == "umbrel":
env["APP_PASSWORD"] = _generate_random(32)
secrets_shown["APP_PASSWORD"] = env["APP_PASSWORD"]
for field in (app.form_fields or []):
ev = field.get("env_variable")
if not ev:
@@ -301,10 +310,31 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
effective_port = port if port else app.port
env, secrets_shown = build_env(app, form_answers)
# Учётные данные по умолчанию из манифеста (Umbrel) — показываем сразу
# после установки: у многих приложений это единственный способ узнать
# логин/пароль админа. deterministic → пароль = APP_PASSWORD (см. build_env).
creds = app.default_credentials or {}
if creds.get("username"):
secrets_shown["USERNAME"] = creds["username"]
default_pwd = env.get("APP_PASSWORD") if creds.get("deterministic") else creds.get("password")
if default_pwd:
secrets_shown["PASSWORD"] = default_pwd
# APP_PORT в окружении должен соответствовать выбранному порту.
if port:
env["APP_PORT"] = str(port)
vmid = get_next_vmid(db, server_id)
# Proxmox nextid не резервирует номер: пока предыдущая установка ещё не
# создала CT, nextid возвращает тот же VMID. Вторая установка тогда падает
# на create, а её retry (идемпотентность по vmid) «подхватывает» ЧУЖОЙ
# контейнер и заливает в него свой compose. Сдвигаемся мимо VMID, уже
# занятых записями installed_apps этого сервера.
taken = {
v for (v,) in db.query(InstalledApp.vmid)
.filter(InstalledApp.server_id == server_id, InstalledApp.vmid.isnot(None))
.all()
}
while vmid in taken:
vmid += 1
# IPAM (опционально): выделяем адрес до старта пайплайна, чтобы ошибка
# (нет свободных IP) вернулась пользователю синхронно.
@@ -323,6 +353,14 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
tipi_version_installed=app.tipi_version,
env_encrypted=json.dumps(env), ip=static_ip, port=effective_port, status="installing",
owner_id=user.id,
# Параметры создания CT — для повтора установки: если контейнер так и
# не был создан, retry должен пересоздать его с ТЕМИ ЖЕ ресурсами,
# storage/bridge и статическим IP из IPAM, а не с дефолтами.
install_params={
"cores": cores, "memory": memory, "disk": disk,
"storage": storage, "bridge": bridge, "ip_config": ip_config,
"port_explicit": bool(port),
},
)
db.add(ia)
db.commit()
@@ -334,6 +372,7 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
_run_install, ia.id, op.id, user.id, app.compose_yaml, env, golden,
cores, memory, disk, server_id, node, name, effective_port, ia.version_installed, vmid,
storage, bridge, ip_config, static_ip, app.source or "runtipi",
app.data_files or {}, bool(port),
)
return {"installed_app_id": ia.id, "operation_id": op.id, "vmid": vmid, "secrets": secrets_shown}
@@ -352,16 +391,18 @@ def _new_operation(db, installed_app_id: int, op_type: str, user_id: int,
def _run_install(installed_app_id, operation_id, user_id, compose_yaml, env, ostemplate,
cores, memory, disk, server_id, node, name, port, version, vmid,
storage, bridge, ip_config=None, static_ip=None, source="runtipi"):
storage, bridge, ip_config=None, static_ip=None, source="runtipi",
data_files=None, port_explicit=False):
"""Тело установки в пуле потоков."""
db = SessionLocal()
try:
spec = InstallSpec(
server_id=server_id, ostemplate=ostemplate, name=name,
compose_yaml=compose_yaml, port=port or 80, env_vars=env,
compose_yaml=compose_yaml, port=port or 80, port_explicit=port_explicit,
env_vars=env,
node=node, vmid=vmid, cores=cores, memory=memory, disk=disk,
storage=storage, bridge=bridge, ip_config=ip_config, static_ip=static_ip,
source=source or "runtipi",
source=source or "runtipi", data_files=data_files or {},
)
def on_step(step: str, progress: int) -> None:
@@ -425,13 +466,20 @@ def retry_install(db, user, installed_app_id: int) -> dict:
db.commit()
op = _new_operation(db, ia.id, "install", user.id)
# Контейнер уже создан (идемпотентный повтор): net0 не применяется заново,
# поэтому ip_config не нужен. Известный статический IP передаём, чтобы не
# ждать выдачи впустую (для DHCP он None → ожидание по-старому).
# Повтор идемпотентен по vmid: существующий контейнер не пересоздаётся
# (тогда params/ip_config игнорируются pайплайном). Если же CT так и не был
# создан — пересоздаём с исходными параметрами установки (install_params),
# включая статический IP из IPAM; для старых записей без install_params
# остаются прежние дефолты. Известный IP (ia.ip) передаём как static_ip,
# чтобы не ждать выдачи DHCP впустую.
params = ia.install_params or {}
_engine_executor.submit(
_run_install, ia.id, op.id, user.id, compose, env, golden,
2, 2048, 8, ia.server_id, ia.node, ia.name, ia.port, ia.version_installed, ia.vmid,
"local-lvm", "vmbr0", None, ia.ip, (app.source or "runtipi"),
params.get("cores", 2), params.get("memory", 2048), params.get("disk", 8),
ia.server_id, ia.node, ia.name, ia.port, ia.version_installed, ia.vmid,
params.get("storage", "local-lvm"), params.get("bridge", "vmbr0"),
params.get("ip_config"), ia.ip, (app.source or "runtipi"),
(app.data_files or {}), bool(params.get("port_explicit")),
)
return {"installed_app_id": ia.id, "operation_id": op.id}
@@ -552,13 +600,14 @@ def update(db, user, installed_app_id: int, form_answers: Optional[dict] = None)
_engine_executor.submit(
_run_update, ia.id, op.id, user.id, app.compose_yaml,
str(app.version) if app.version is not None else None, app.tipi_version,
form_answers or {}, (app.source or "runtipi"),
form_answers or {}, (app.source or "runtipi"), (app.data_files or {}),
)
return {"installed_app_id": ia.id, "operation_id": op.id}
def _run_update(installed_app_id, operation_id, user_id, compose_yaml,
new_version, new_tipi, form_answers, source="runtipi"):
new_version, new_tipi, form_answers, source="runtipi",
data_files=None):
db = SessionLocal()
try:
ia = db.get(InstalledApp, installed_app_id)
@@ -594,8 +643,12 @@ def _run_update(installed_app_id, operation_id, user_id, compose_yaml,
env[k] = str(v)
# 3-4. push нового compose → pull && up → health
status = poc_update(server, client, ia.vmid, ia.node, compose_yaml,
env, ia.port or 80, ia.ip, on_step, source=source or "runtipi")
status, eff_port = poc_update(
server, client, ia.vmid, ia.node, compose_yaml,
env, ia.port or 80, ia.ip, on_step,
source=source or "runtipi", data_files=data_files or {},
port_explicit=bool((ia.install_params or {}).get("port_explicit")),
)
if status == "running":
ia.status = "running"
@@ -603,6 +656,10 @@ def _run_update(installed_app_id, operation_id, user_id, compose_yaml,
ia.tipi_version_installed = new_tipi
ia.update_available = False
ia.env_encrypted = json.dumps(env)
# Нормализация compose могла опубликовать другой порт — фиксируем
# его в БД, иначе url/health-check будут указывать не туда.
if eff_port:
ia.port = eff_port
db.commit()
_prune_snapshots(client, ia.node, ia.vmid, keep=snapname) # хранить последний 1
_finish_op(operation_id, user_id, "completed")
@@ -788,9 +845,13 @@ def reconcile(db) -> dict:
continue
try:
_server, client = _client_for(db, ia.server_id)
if not client.is_connected():
# Сервер целиком недоступен (сеть/креды) — это ничего не говорит
# о судьбе контейнера; не помечать все приложения orphaned.
continue
st = client.get_container_status(ia.node, ia.vmid)
except Exception:
st = None
continue
if not st:
new_status = "orphaned"

View File

@@ -1542,6 +1542,26 @@ def migrate_appstore_catalog_source(conn):
logger.info("✓ App Store catalog_apps.source column added")
def migrate_appstore_catalog_data_files(conn):
"""Add data_files column to catalog_apps (файлы приложения для bind-mount'ов)."""
if not table_exists(conn, 'catalog_apps'):
return
if column_exists(conn, 'catalog_apps', 'data_files'):
return
conn.execute(text("ALTER TABLE catalog_apps ADD COLUMN data_files JSONB"))
logger.info("✓ App Store catalog_apps.data_files column added")
def migrate_appstore_default_credentials(conn):
"""Add default_credentials column to catalog_apps (учётные данные из манифеста)."""
if not table_exists(conn, 'catalog_apps'):
return
if column_exists(conn, 'catalog_apps', 'default_credentials'):
return
conn.execute(text("ALTER TABLE catalog_apps ADD COLUMN default_credentials JSONB"))
logger.info("✓ App Store catalog_apps.default_credentials column added")
def migrate_script_repo_metadata_format(conn):
"""Add metadata_format column to script_git_repos (header-comment | community-scripts-ct)."""
if not table_exists(conn, 'script_git_repos'):
@@ -1601,6 +1621,8 @@ def migrate_appstore_installed(conn):
# M4: версия, зафиксированная в pre-update снапшоте (для отката версии в БД)
add_column_if_not_exists(conn, 'installed_apps', 'snapshot_version', 'VARCHAR(50)')
add_column_if_not_exists(conn, 'installed_apps', 'snapshot_tipi_version', 'INTEGER')
# Параметры создания CT для корректного повтора установки (retry)
add_column_if_not_exists(conn, 'installed_apps', 'install_params', 'JSONB')
def migrate_ipam_network_workspace(conn):
@@ -1977,6 +1999,22 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"App operations server_id migration: {e}")
conn.rollback()
# Migration 38: catalog_apps.data_files column (файлы приложения из репозитория)
try:
migrate_appstore_catalog_data_files(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store catalog data_files migration: {e}")
conn.rollback()
# Migration 39: catalog_apps.default_credentials column (учётные данные из манифеста)
try:
migrate_appstore_default_credentials(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store default_credentials migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)

View File

@@ -0,0 +1,75 @@
import { Loader2, Minus, X, CheckCircle2, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
/**
* Сворачивание модалок с долгими операциями (установка приложения, сборка
* золотого шаблона и т.п.): кнопка «Свернуть» в шапке диалога + плавающая
* плашка прогресса внизу справа. Родительский компонент диалога остаётся
* смонтированным (сворачивание меняет только open у <Dialog>), поэтому
* WS-подписки и опросы прогресса продолжают работать.
*/
export function DialogMinimizeButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<Button
variant="ghost"
size="icon-sm"
className="absolute top-2 right-10"
onClick={onClick}
title={title}
>
<Minus className="h-4 w-4" />
<span className="sr-only">{title}</span>
</Button>
);
}
export type MinimizedStatus = 'idle' | 'running' | 'done' | 'failed';
interface PillProps {
title: string;
subtitle?: string | null;
progress?: number; // 0..100; undefined → без прогресс-бара
status: MinimizedStatus;
onRestore: () => void; // клик по плашке — развернуть диалог
onClose: () => void; // крестик — закрыть совсем
}
export function MinimizedTaskPill({ title, subtitle, progress, status, onRestore, onClose }: PillProps) {
return (
<div
role="button"
tabIndex={0}
className="fixed bottom-4 right-4 z-50 w-72 cursor-pointer rounded-lg border bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10 transition-colors hover:bg-accent/40"
onClick={onRestore}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onRestore(); }}
>
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />}
{status === 'done' && <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />}
{status === 'failed' && <XCircle className="h-4 w-4 shrink-0 text-destructive" />}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div>
{subtitle && <div className="truncate text-xs text-muted-foreground">{subtitle}</div>}
</div>
<Button
size="icon-sm"
variant="ghost"
className="shrink-0"
onClick={(e) => { e.stopPropagation(); onClose(); }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{typeof progress === 'number' && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full transition-all', status === 'failed' ? 'bg-destructive' : 'bg-primary')}
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,9 @@ 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 {
DialogMinimizeButton, MinimizedTaskPill, type MinimizedStatus,
} from '@/components/shared/MinimizableDialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
@@ -22,6 +25,9 @@ export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
const [serverId, setServerId] = useState('');
const [node, setNode] = useState('');
// Свёрнутое состояние: диалог скрыт, прогресс сборки — в плашке внизу справа.
// open остаётся true, поэтому опрос статуса (useGoldenTemplateStatus) продолжается.
const [minimized, setMinimized] = useState(false);
const [templateStorage, setTemplateStorage] = useState('local');
const [rootfsStorage, setRootfsStorage] = useState('local-lvm');
const [force, setForce] = useState(false);
@@ -67,9 +73,19 @@ export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
);
};
const handleOpenChange = (o: boolean) => {
if (!o) setMinimized(false);
onOpenChange(o);
};
const pillStatus: MinimizedStatus =
running ? 'running' : op?.status === 'completed' ? 'done' : op?.status === 'failed' ? 'failed' : 'idle';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<>
<Dialog open={open && !minimized} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogMinimizeButton onClick={() => setMinimized(true)} title={t('appstore.minimize')} />
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HardDriveDownload className="h-5 w-5 text-primary" />
@@ -165,7 +181,7 @@ export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t('common.close', 'Закрыть')}
</Button>
<Button onClick={handleBuild} disabled={!canSubmit}>
@@ -177,5 +193,18 @@ export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
</DialogFooter>
</DialogContent>
</Dialog>
{open && minimized && (
<MinimizedTaskPill
title={t('appstore.golden.title', 'Золотой LXC-шаблон')}
subtitle={op?.status === 'failed'
? (op.error_text || t('appstore.golden.failed', 'Ошибка сборки'))
: (lastStep || t('appstore.golden.working', 'Выполняется...'))}
progress={op ? op.progress : undefined}
status={pillStatus}
onRestore={() => setMinimized(false)}
onClose={() => handleOpenChange(false)}
/>
)}
</>
);
}

View File

@@ -6,6 +6,9 @@ import { Loader2, CheckCircle2, XCircle, Copy, ExternalLink } from 'lucide-react
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
DialogMinimizeButton, MinimizedTaskPill, type MinimizedStatus,
} from '@/components/shared/MinimizableDialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
@@ -92,6 +95,9 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
}, [storages]);
const [phase, setPhase] = useState<Phase>('form');
// Свёрнутое состояние: диалог скрыт, внизу справа — плашка прогресса.
// Компонент остаётся смонтированным, поэтому WS/опрос прогресса живут.
const [minimized, setMinimized] = useState(false);
const [installedAppId, setInstalledAppId] = useState<number | null>(null);
const [op, setOp] = useState<AppOperation | null>(null);
const [secrets, setSecrets] = useState<Record<string, string>>({});
@@ -124,7 +130,7 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
);
const reset = () => {
setPhase('form'); setOp(null); setInstalledAppId(null); setSecrets({});
setPhase('form'); setMinimized(false); setOp(null); setInstalledAppId(null); setSecrets({});
setName(app.app_id); setServerId(''); setNode(''); setAnswers({});
setAdvanced(false); setCores(2); setMemory(2048); setDisk(8);
setStorage('local-lvm'); setBridge('vmbr0');
@@ -173,9 +179,21 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
const progress = op?.progress ?? 0;
// Статус для свёрнутой плашки.
const pillStatus: MinimizedStatus =
phase === 'progress' ? 'running' : phase === 'done' ? 'done' : phase === 'failed' ? 'failed' : 'idle';
const lastStep = op?.steps_log?.length ? op.steps_log[op.steps_log.length - 1].step : null;
const pillSubtitle =
phase === 'progress' ? (lastStep || t('appstore.installing'))
: phase === 'done' ? t('appstore.done')
: phase === 'failed' ? t('appstore.install_failed')
: null;
return (
<Dialog open={open} onOpenChange={handleClose}>
<>
<Dialog open={open && !minimized} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogMinimizeButton onClick={() => setMinimized(true)} title={t('appstore.minimize')} />
<DialogHeader>
<DialogTitle>{t('appstore.install_title', { name: app.name })}</DialogTitle>
</DialogHeader>
@@ -403,6 +421,17 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
</DialogFooter>
</DialogContent>
</Dialog>
{open && minimized && (
<MinimizedTaskPill
title={t('appstore.install_title', { name: app.name })}
subtitle={pillSubtitle}
progress={phase === 'progress' || phase === 'failed' ? progress : undefined}
status={pillStatus}
onRestore={() => setMinimized(false)}
onClose={() => handleClose(false)}
/>
)}
</>
);
}

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
Package, Store, ExternalLink, RotateCw, Trash2, Loader2,
Play, Square, RefreshCcw, FileText, ArrowUpCircle, Undo2,
Play, Square, RefreshCcw, FileText, ArrowUpCircle, Undo2, KeyRound, Copy,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -15,6 +15,7 @@ import {
import {
useInstalledApps, useAppstoreRealtime, useRetryApp, useDeleteApp,
useLifecycleAction, useReconcile, useAppLogs, useUpdateApp, useRollbackApp,
useAppCredentials,
} from '@/hooks/use-appstore';
import { useConfirm } from '@/components/shared/ConfirmDialog';
import type { InstalledApp } from '@/types/appstore';
@@ -32,6 +33,7 @@ export default function MyAppsPage() {
const update = useUpdateApp();
const rollback = useRollbackApp();
const [logsApp, setLogsApp] = useState<InstalledApp | null>(null);
const [credsApp, setCredsApp] = useState<InstalledApp | null>(null);
// ТЗ 6.6 — реконсиляция статусов при открытии экрана.
useEffect(() => {
@@ -178,6 +180,9 @@ export default function MyAppsPage() {
<FileText className="h-3.5 w-3.5" />
</Button>
)}
<Button size="icon-sm" variant="ghost" title={t('appstore.credentials_btn')} onClick={() => setCredsApp(a)}>
<KeyRound className="h-3.5 w-3.5" />
</Button>
{a.update_available && ['running', 'stopped', 'update_failed'].includes(a.status) && (
<Button size="icon-sm" variant="ghost" className="text-primary" title={t('appstore.update')} onClick={() => handleUpdate(a)} disabled={busy}>
<ArrowUpCircle className="h-3.5 w-3.5" />
@@ -208,10 +213,50 @@ export default function MyAppsPage() {
)}
<LogsDialog app={logsApp} onClose={() => setLogsApp(null)} />
<CredentialsDialog app={credsApp} onClose={() => setCredsApp(null)} />
</div>
);
}
function CredentialsDialog({ app, onClose }: { app: InstalledApp | null; onClose: () => void }) {
const { t } = useTranslation();
const { data: creds, isFetching } = useAppCredentials(app?.id ?? 0, !!app);
const entries = Object.entries(creds ?? {});
const copy = (v: string) => {
navigator.clipboard.writeText(v);
toast.success(t('appstore.copied'));
};
return (
<Dialog open={!!app} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('appstore.credentials_title', { name: app?.name ?? '' })}</DialogTitle>
</DialogHeader>
{isFetching && !creds ? (
<div className="py-8 text-center text-sm text-muted-foreground">{t('appstore.loading')}</div>
) : entries.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">{t('appstore.no_credentials')}</p>
) : (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('appstore.credentials_hint')}</p>
{entries.map(([k, v]) => (
<div key={k} className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
<span className="font-mono text-xs text-muted-foreground">{k}</span>
<span className="ml-auto truncate font-mono text-sm">{v}</span>
<Button size="icon-sm" variant="ghost" title={t('appstore.copy')} onClick={() => copy(v)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
);
}
function LogsDialog({ app, onClose }: { app: InstalledApp | null; onClose: () => void }) {
const { t } = useTranslation();
const { data: logs, isFetching, refetch } = useAppLogs(app?.id ?? 0, !!app);

View File

@@ -8,8 +8,8 @@ import type {
} from '@/types/appstore';
export const appstoreKeys = {
catalog: (q?: string, category?: string, source?: string) =>
['appstore-catalog', q ?? '', category ?? '', source ?? ''] as const,
catalog: (q?: string, category?: string, source?: string, availableOnly = false) =>
['appstore-catalog', q ?? '', category ?? '', source ?? '', availableOnly] as const,
catalogMeta: ['appstore-catalog-meta'] as const,
catalogApp: (id: string) => ['appstore-catalog-app', id] as const,
installed: ['appstore-installed'] as const,
@@ -27,7 +27,7 @@ export function useCatalog(q?: string, category?: string, source?: string, avail
if (availableOnly) params.set('available_only', 'true');
const qs = params.toString();
return useQuery({
queryKey: appstoreKeys.catalog(q, category, source),
queryKey: appstoreKeys.catalog(q, category, source, availableOnly),
queryFn: () => apiClient.get<CatalogListResponse>(`/api/appstore/catalog${qs ? `?${qs}` : ''}`),
});
}
@@ -201,6 +201,17 @@ export function useReconcile() {
});
}
export function useAppCredentials(id: number, enabled: boolean) {
return useQuery({
queryKey: ['appstore-credentials', id],
queryFn: () =>
apiClient.get<{ credentials: Record<string, string> }>(`/api/appstore/apps/${id}/credentials`)
.then((r) => r.credentials),
enabled: enabled && id > 0,
refetchOnWindowFocus: false,
});
}
export function useAppLogs(id: number, enabled: boolean) {
return useQuery({
queryKey: ['appstore-logs', id],

View File

@@ -139,6 +139,11 @@
"installing": "Installing…",
"install_started": "Installation started",
"credentials": "Credentials (shown once — copy them now)",
"credentials_title": "Credentials — {{name}}",
"credentials_btn": "Credentials",
"no_credentials": "No stored credentials for this app. Check the app logs or its documentation.",
"credentials_hint": "Default login and password from the catalog plus secrets generated at install time. Change the password in the app after first login.",
"minimize": "Minimize",
"copy": "Copy",
"copied": "Copied",
"done": "Done",

View File

@@ -139,6 +139,11 @@
"installing": "Установка…",
"install_started": "Установка запущена",
"credentials": "Учётные данные (показаны один раз — скопируйте сейчас)",
"credentials_title": "Учётные данные — {{name}}",
"credentials_btn": "Учётные данные",
"no_credentials": "Для этого приложения нет сохранённых учётных данных. Проверьте логи приложения или его документацию.",
"credentials_hint": "Логин и пароль по умолчанию из каталога и секреты, сгенерированные при установке. После первого входа смените пароль в самом приложении.",
"minimize": "Свернуть",
"copy": "Копировать",
"copied": "Скопировано",
"done": "Готово",