fix(appstore): data files, надёжность установки/удаления и сворачиваемые диалоги
Доставка файлов приложения из каталога (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:
@@ -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")
|
||||
|
||||
@@ -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 решит, что ресурсы освобождены, пока нода ещё занята
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
75
frontend/src/components/shared/MinimizableDialog.tsx
Normal file
75
frontend/src/components/shared/MinimizableDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -139,6 +139,11 @@
|
||||
"installing": "Установка…",
|
||||
"install_started": "Установка запущена",
|
||||
"credentials": "Учётные данные (показаны один раз — скопируйте сейчас)",
|
||||
"credentials_title": "Учётные данные — {{name}}",
|
||||
"credentials_btn": "Учётные данные",
|
||||
"no_credentials": "Для этого приложения нет сохранённых учётных данных. Проверьте логи приложения или его документацию.",
|
||||
"credentials_hint": "Логин и пароль по умолчанию из каталога и секреты, сгенерированные при установке. После первого входа смените пароль в самом приложении.",
|
||||
"minimize": "Свернуть",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
"done": "Готово",
|
||||
|
||||
Reference in New Issue
Block a user