feat(appstore): кастомный порт при установке + мгновенная чистка инстанса
App Store: - поле кастомного порта в мастере установки (переопределяет порт каталога) - валидация диапазона порта 1..65535 (Pydantic Field + EngineError) - APP_PORT в окружении синхронизируется с выбранным портом - при удалении приложения инстанс сразу помечается deleted в кэше vm_instances (soft_delete_vm_instance), не дожидаясь фонового воркера Также включены правки IPAM (привязка сетей к рабочей области, UI WorkspaceSelect) и синхронизации каталога App Store. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -54,3 +54,14 @@ FERNET_KEY=
|
||||
APPSTORE_GOLDEN_TEMPLATE=local:vztmpl/golden-docker_12_amd64.tar.zst
|
||||
# Архитектура целевых нод (amd64|arm64) — для пометки неподдерживаемых приложений.
|
||||
APPSTORE_HOST_ARCH=amd64
|
||||
#
|
||||
# Источники каталога (через запятую): runtipi, umbrel. По умолчанию только
|
||||
# runtipi. Umbrel включается явно — тогда при синхронизации подтянутся ~380
|
||||
# приложений из getumbrel/umbrel-apps + их иконки из галереи.
|
||||
# APPSTORE_SOURCES=runtipi,umbrel
|
||||
APPSTORE_SOURCES=runtipi
|
||||
# Репозитории Umbrel (нужны только если включён источник umbrel):
|
||||
# UMBREL_APPSTORE_REPO=getumbrel/umbrel-apps
|
||||
# UMBREL_APPSTORE_REF=master
|
||||
# Иконки Umbrel лежат отдельно (<app-id>/icon.svg) — скачиваются одним tarball:
|
||||
# UMBREL_APPSTORE_GALLERY_REPO=getumbrel/umbrel-apps-gallery
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...auth import PermissionChecker
|
||||
@@ -39,6 +39,9 @@ class InstallRequest(BaseModel):
|
||||
storage: str = "local-lvm"
|
||||
bridge: str = "vmbr0"
|
||||
ostemplate: Optional[str] = None # переопределение золотого шаблона
|
||||
# Кастомный рабочий порт приложения (переопределяет порт из каталога).
|
||||
# None — использовать порт по умолчанию из config.json приложения.
|
||||
port: Optional[int] = Field(default=None, ge=1, le=65535)
|
||||
# IPAM (опционально): если задан network — IP выделяется из пула IPAM,
|
||||
# иначе контейнер получает адрес по DHCP.
|
||||
ipam_network_id: Optional[int] = None
|
||||
@@ -70,7 +73,7 @@ def install_app(
|
||||
app_id=req.app_id, name=req.name, form_answers=req.form_answers,
|
||||
server_id=req.server_id, node=req.node, cores=req.cores,
|
||||
memory=req.memory, disk=req.disk, storage=req.storage,
|
||||
bridge=req.bridge, ostemplate=req.ostemplate,
|
||||
bridge=req.bridge, ostemplate=req.ostemplate, port=req.port,
|
||||
ipam_network_id=req.ipam_network_id, ipam_pool_id=req.ipam_pool_id,
|
||||
)
|
||||
except engine.EngineError as e:
|
||||
|
||||
@@ -32,6 +32,7 @@ router = APIRouter()
|
||||
def list_catalog(
|
||||
q: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
available_only: bool = False,
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -41,6 +42,8 @@ def list_catalog(
|
||||
query = db.query(CatalogApp)
|
||||
if available_only:
|
||||
query = query.filter(CatalogApp.available.is_(True))
|
||||
if source:
|
||||
query = query.filter(CatalogApp.source == source.strip().lower())
|
||||
if q:
|
||||
like = f"%{q.strip().lower()}%"
|
||||
query = query.filter(or_(
|
||||
|
||||
@@ -8,6 +8,7 @@ import ipaddress as ipaddress_module
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
@@ -30,29 +31,61 @@ router = APIRouter()
|
||||
|
||||
@router.get("/api/networks", response_model=List[IPAMNetworkResponse])
|
||||
def get_networks(
|
||||
request: Request,
|
||||
is_active: Optional[bool] = None,
|
||||
proxmox_server_id: Optional[int] = None,
|
||||
workspace_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("ipam:view"))
|
||||
):
|
||||
"""Get all IPAM networks"""
|
||||
"""Get all IPAM networks.
|
||||
|
||||
Filtered by the active workspace (X-Active-Workspace header): networks bound to
|
||||
that workspace plus global networks (workspace_id IS NULL). Admin "All workspaces"
|
||||
(header 0/absent) shows everything. An explicit ?workspace_id= query param overrides.
|
||||
"""
|
||||
from ..models import Workspace
|
||||
|
||||
query = db.query(IPAMNetwork)
|
||||
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(IPAMNetwork.is_active == is_active)
|
||||
|
||||
|
||||
if proxmox_server_id:
|
||||
query = query.filter(IPAMNetwork.proxmox_server_id == proxmox_server_id)
|
||||
|
||||
|
||||
# Resolve workspace scope: explicit query param wins, else the active-workspace header.
|
||||
ws_filter = workspace_id
|
||||
if ws_filter is None:
|
||||
raw = request.headers.get("X-Active-Workspace")
|
||||
if raw is not None:
|
||||
try:
|
||||
parsed = int(raw)
|
||||
ws_filter = parsed if parsed != 0 else None
|
||||
except ValueError:
|
||||
ws_filter = None
|
||||
if ws_filter:
|
||||
# Networks of this workspace + global (unassigned) networks.
|
||||
query = query.filter(
|
||||
or_(IPAMNetwork.workspace_id == ws_filter, IPAMNetwork.workspace_id.is_(None))
|
||||
)
|
||||
|
||||
networks = query.order_by(IPAMNetwork.name).all()
|
||||
|
||||
|
||||
# Get server names for networks
|
||||
server_ids = [n.proxmox_server_id for n in networks if n.proxmox_server_id]
|
||||
servers = {}
|
||||
if server_ids:
|
||||
server_records = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
|
||||
servers = {s.id: s.name for s in server_records}
|
||||
|
||||
|
||||
# Get workspace names for networks
|
||||
ws_ids = [n.workspace_id for n in networks if n.workspace_id]
|
||||
workspaces = {}
|
||||
if ws_ids:
|
||||
ws_records = db.query(Workspace.id, Workspace.name).filter(Workspace.id.in_(ws_ids)).all()
|
||||
workspaces = {w.id: w.name for w in ws_records}
|
||||
|
||||
# Add computed stats to each network
|
||||
ipam = IPAMService(db)
|
||||
result = []
|
||||
@@ -64,10 +97,11 @@ def get_networks(
|
||||
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
|
||||
'available_ips': stats.get('available_ips', 0),
|
||||
'utilization_percent': stats.get('utilization_percent', 0),
|
||||
'server_name': servers.get(network.proxmox_server_id) if network.proxmox_server_id else None
|
||||
'server_name': servers.get(network.proxmox_server_id) if network.proxmox_server_id else None,
|
||||
'workspace_name': workspaces.get(network.workspace_id) if network.workspace_id else None,
|
||||
}
|
||||
result.append(network_dict)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -142,9 +176,11 @@ def create_network(
|
||||
|
||||
network = IPAMNetwork(**network_data.model_dump())
|
||||
db.add(network)
|
||||
db.flush() # assign id before clearing other defaults
|
||||
_enforce_single_default(db, network)
|
||||
db.commit()
|
||||
db.refresh(network)
|
||||
|
||||
|
||||
logger.info(f"User {current_user.username} created network {network.name} ({network.network})")
|
||||
return network
|
||||
|
||||
@@ -164,14 +200,27 @@ def update_network(
|
||||
update_data = network_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(network, field, value)
|
||||
|
||||
|
||||
_enforce_single_default(db, network)
|
||||
db.commit()
|
||||
db.refresh(network)
|
||||
|
||||
|
||||
logger.info(f"User {current_user.username} updated network {network.name}")
|
||||
return network
|
||||
|
||||
|
||||
def _enforce_single_default(db: Session, network: IPAMNetwork) -> None:
|
||||
"""Keep at most one default network per workspace: if this one is marked
|
||||
default, clear the flag on siblings in the same workspace."""
|
||||
if not network.is_default or network.workspace_id is None:
|
||||
return
|
||||
db.query(IPAMNetwork).filter(
|
||||
IPAMNetwork.workspace_id == network.workspace_id,
|
||||
IPAMNetwork.id != network.id,
|
||||
IPAMNetwork.is_default == True, # noqa: E712
|
||||
).update({IPAMNetwork.is_default: False}, synchronize_session=False)
|
||||
|
||||
|
||||
@router.delete("/api/networks/{network_id}")
|
||||
def delete_network(
|
||||
network_id: int,
|
||||
|
||||
@@ -70,6 +70,7 @@ class InstallSpec:
|
||||
# (тогда не ждём выдачи по DHCP).
|
||||
ip_config: Optional[str] = None # напр. "ip=10.0.0.5/24,gw=10.0.0.1"
|
||||
static_ip: Optional[str] = None # напр. "10.0.0.5"
|
||||
source: str = "runtipi" # источник каталога: runtipi | umbrel
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -127,25 +128,176 @@ def _shq(s: str) -> str:
|
||||
return "'" + s.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _normalize_compose(compose_yaml: str) -> str:
|
||||
"""Привести compose Runtipi к самодостаточному виду для standalone-запуска.
|
||||
def _to_int(v) -> Optional[int]:
|
||||
"""Аккуратно привести значение к int (int или строка-число), иначе None."""
|
||||
if isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
if isinstance(v, str) and v.strip().isdigit():
|
||||
return int(v.strip())
|
||||
return None
|
||||
|
||||
Runtipi-приложения ссылаются в сервисах на сеть `tipi_main_network`, но
|
||||
рассчитывают, что top-level объявление сети добавит рантайм Runtipi. У нас
|
||||
его нет → `docker compose up` падает с «refers to undefined network ...:
|
||||
invalid compose project». Сеть мы создаём заранее (`docker network create`),
|
||||
поэтому здесь достаточно объявить любую упомянутую, но необъявленную сеть как
|
||||
external. При любой ошибке разбора возвращаем исходный compose без изменений.
|
||||
|
||||
def _find_main_service(services: Dict) -> Optional[str]:
|
||||
"""Определить главный сервис Runtipi-compose.
|
||||
|
||||
Приоритет: сервис с `x-runtipi.is_main: true`. Если такого нет, но сервис
|
||||
единственный — берём его. Иначе None (не можем однозначно определить).
|
||||
"""
|
||||
for name, svc in services.items():
|
||||
if isinstance(svc, dict):
|
||||
rt = svc.get("x-runtipi")
|
||||
if isinstance(rt, dict) and rt.get("is_main"):
|
||||
return name
|
||||
if len(services) == 1:
|
||||
return next(iter(services))
|
||||
return None
|
||||
|
||||
|
||||
def _compose_env_get(env, key: str) -> Optional[str]:
|
||||
"""Достать значение переменной из секции environment (dict или list-формат)."""
|
||||
if isinstance(env, dict):
|
||||
v = env.get(key)
|
||||
return str(v) if v is not None else None
|
||||
if isinstance(env, list):
|
||||
pref = key + "="
|
||||
for item in env:
|
||||
s = str(item)
|
||||
if s.startswith(pref):
|
||||
return s[len(pref):]
|
||||
return None
|
||||
|
||||
|
||||
# Сервисы рантайма Umbrel, которые не запустятся вне Umbrel и должны быть
|
||||
# вырезаны из compose перед standalone-запуском в LXC.
|
||||
def _is_umbrel_infra_service(name: str) -> bool:
|
||||
low = name.lower()
|
||||
return low == "app_proxy" or low == "tor" or low.endswith("_tor") or low.endswith("-tor")
|
||||
|
||||
|
||||
def _strip_dangling_depends(services: Dict, removed: set) -> None:
|
||||
"""Убрать depends_on-ссылки на удалённые сервисы (иначе compose up падает)."""
|
||||
for svc in services.values():
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
dep = svc.get("depends_on")
|
||||
if isinstance(dep, list):
|
||||
new = [d for d in dep if d not in removed]
|
||||
if new:
|
||||
svc["depends_on"] = new
|
||||
else:
|
||||
svc.pop("depends_on", None)
|
||||
elif isinstance(dep, dict):
|
||||
for r in removed:
|
||||
dep.pop(r, None)
|
||||
if not dep:
|
||||
svc.pop("depends_on", None)
|
||||
|
||||
|
||||
def _umbrel_main_service(services: Dict, app_host: Optional[str]) -> Optional[str]:
|
||||
"""Определить главный сервис Umbrel-compose.
|
||||
|
||||
Приоритет — сервис, на который указывает app_proxy (APP_HOST). Затем —
|
||||
единственный сервис, затем первый с образом.
|
||||
"""
|
||||
if not services:
|
||||
return None
|
||||
if app_host:
|
||||
for name in services:
|
||||
if name == app_host or name in app_host.split("_") or name in app_host:
|
||||
return name
|
||||
if len(services) == 1:
|
||||
return next(iter(services))
|
||||
for name, svc in services.items():
|
||||
if isinstance(svc, dict) and svc.get("image"):
|
||||
return name
|
||||
return next(iter(services))
|
||||
|
||||
|
||||
def _prepare_umbrel(doc: dict) -> tuple[Optional[str], Optional[int], bool]:
|
||||
"""Адаптировать Umbrel-compose: снять порт из app_proxy, вырезать рантайм
|
||||
Umbrel, определить главный сервис.
|
||||
|
||||
Возвращает (main_service, internal_port, changed).
|
||||
"""
|
||||
services = doc.get("services")
|
||||
if not isinstance(services, dict):
|
||||
return None, None, False
|
||||
|
||||
# Порт контейнера и целевой сервис задаёт app_proxy Umbrel.
|
||||
ap = services.get("app_proxy")
|
||||
internal: Optional[int] = None
|
||||
app_host: Optional[str] = None
|
||||
if isinstance(ap, dict):
|
||||
env = ap.get("environment")
|
||||
internal = _to_int(_compose_env_get(env, "APP_PORT"))
|
||||
app_host = _compose_env_get(env, "APP_HOST")
|
||||
|
||||
# Вырезать app_proxy/tor — их образов у нас нет, они завесят/уронят up.
|
||||
removed = {n for n in list(services.keys()) if _is_umbrel_infra_service(n)}
|
||||
for n in removed:
|
||||
services.pop(n, None)
|
||||
if removed:
|
||||
_strip_dangling_depends(services, removed)
|
||||
|
||||
main = _umbrel_main_service(services, app_host)
|
||||
return main, internal, bool(removed)
|
||||
|
||||
|
||||
def _resolve_main_and_port(doc: dict, services: Dict, source: str
|
||||
) -> tuple[Optional[str], Optional[int], bool]:
|
||||
"""Определить (главный_сервис, внутренний_порт, changed) по источнику."""
|
||||
if source == "umbrel":
|
||||
return _prepare_umbrel(doc)
|
||||
# runtipi: главный сервис и порт объявлены через x-runtipi.
|
||||
main = _find_main_service(services)
|
||||
internal = None
|
||||
if main is not None:
|
||||
svc = services.get(main)
|
||||
rt = svc.get("x-runtipi") if isinstance(svc, dict) else None
|
||||
internal = _to_int(rt.get("internal_port")) if isinstance(rt, dict) else None
|
||||
return main, internal, False
|
||||
|
||||
|
||||
def _normalize_compose(compose_yaml: str, host_port: Optional[int] = None,
|
||||
*, source: str = "runtipi") -> tuple[str, Optional[int]]:
|
||||
"""Привести compose к самодостаточному виду для standalone-запуска в LXC.
|
||||
|
||||
Делает то, что в родном рантайме (Runtipi/Umbrel) выполняет их оркестратор:
|
||||
|
||||
1. Объявляет упомянутые, но необъявленные сети (напр. `tipi_main_network`)
|
||||
как external — иначе `docker compose up` падает с «refers to undefined
|
||||
network …». Сеть создаётся заранее на ноде (`docker network create`).
|
||||
|
||||
2. Для Umbrel: вырезает сервисы рантайма (app_proxy, tor) — их образов у нас
|
||||
нет, а порт контейнера берётся из `app_proxy.environment.APP_PORT`.
|
||||
|
||||
3. Публикует порт главного сервиса на хост LXC. И Runtipi (порт в
|
||||
`x-runtipi.internal_port`), и Umbrel (порт в app_proxy) обычно НЕ имеют
|
||||
секции `ports:` — наружу его пробрасывал бы их прокси, которого у нас нет.
|
||||
Без публикации приложение недоступно по IP контейнера (health-check не
|
||||
проходит). Поэтому главному сервису добавляем `ports: ["<host>:<internal>"]`.
|
||||
|
||||
Возвращает `(normalized_yaml, published_port)`. При любой ошибке разбора —
|
||||
`(compose_yaml, None)` (используем исходный compose как есть).
|
||||
"""
|
||||
try:
|
||||
doc = yaml.safe_load(compose_yaml)
|
||||
if not isinstance(doc, dict):
|
||||
return compose_yaml
|
||||
return compose_yaml, None
|
||||
|
||||
services = doc.get("services")
|
||||
if not isinstance(services, dict):
|
||||
return compose_yaml
|
||||
return compose_yaml, None
|
||||
|
||||
# Источнико-специфичная подготовка (для Umbrel мутирует services).
|
||||
main, internal, changed = _resolve_main_and_port(doc, services, source)
|
||||
services = doc.get("services")
|
||||
if not isinstance(services, dict):
|
||||
return compose_yaml, None
|
||||
|
||||
# (1) Необъявленные сети → external.
|
||||
referenced: set[str] = set()
|
||||
for svc in services.values():
|
||||
if not isinstance(svc, dict):
|
||||
@@ -156,27 +308,71 @@ def _normalize_compose(compose_yaml: str) -> str:
|
||||
elif isinstance(nets, dict):
|
||||
referenced.update(str(n) for n in nets.keys())
|
||||
|
||||
if not referenced:
|
||||
return compose_yaml
|
||||
if referenced:
|
||||
top = doc.get("networks")
|
||||
if not isinstance(top, dict):
|
||||
top = {}
|
||||
for net in referenced:
|
||||
if net not in top:
|
||||
top[net] = {"external": True}
|
||||
changed = True
|
||||
if changed:
|
||||
doc["networks"] = top
|
||||
|
||||
top = doc.get("networks")
|
||||
if not isinstance(top, dict):
|
||||
top = {}
|
||||
changed = False
|
||||
for net in referenced:
|
||||
if net not in top:
|
||||
# Сеть создаётся заранее на ноде → подключаемся как external.
|
||||
top[net] = {"external": True}
|
||||
changed = True
|
||||
# Для Umbrel, если порт контейнера не удалось снять из app_proxy, но
|
||||
# известен порт из каталога — используем его как внутренний.
|
||||
if source == "umbrel" and internal is None and host_port:
|
||||
internal = host_port
|
||||
|
||||
# (2) Публикация порта главного сервиса на хост LXC.
|
||||
published_port: Optional[int] = None
|
||||
svc = services.get(main) if main is not None else None
|
||||
if isinstance(svc, dict):
|
||||
if internal:
|
||||
# Уважаем уже объявленную публикацию (не переопределяем).
|
||||
if not svc.get("ports"):
|
||||
# host_port из каталога, если задан и не дефолтные 80/0; иначе
|
||||
# публикуем на том же номере, что слушает контейнер.
|
||||
pub = host_port if (host_port and 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
|
||||
elif svc.get("ports"):
|
||||
# Внутренний порт неизвестен, но публикация уже есть в compose.
|
||||
ports = svc.get("ports")
|
||||
if isinstance(ports, list) and ports:
|
||||
published_port = _to_int(str(ports[0]).split(":")[0])
|
||||
|
||||
if not changed:
|
||||
return compose_yaml
|
||||
return compose_yaml, published_port
|
||||
|
||||
doc["networks"] = top
|
||||
return yaml.safe_dump(doc, sort_keys=False, allow_unicode=True)
|
||||
return yaml.safe_dump(doc, sort_keys=False, allow_unicode=True), published_port
|
||||
except Exception as e:
|
||||
logger.warning(f"[appstore-poc] normalize compose failed, using raw: {e}")
|
||||
return compose_yaml
|
||||
return compose_yaml, None
|
||||
|
||||
|
||||
def _platform_env(env: Dict[str, str], ip: str, source: str) -> Dict[str, str]:
|
||||
"""Заполнить IP-зависимые платформенные переменные окружения (ТЗ 5.3).
|
||||
|
||||
Общие: APP_DOMAIN/APP_HOST/APP_IP. runtipi → LOCAL_DOMAIN. umbrel →
|
||||
DEVICE_DOMAIN_NAME/DEVICE_HOSTNAME/APP_SEED (частые ссылки в их compose).
|
||||
"""
|
||||
env.setdefault("APP_DOMAIN", ip)
|
||||
env.setdefault("APP_HOST", ip)
|
||||
env["APP_IP"] = ip
|
||||
if source == "umbrel":
|
||||
env.setdefault("DEVICE_DOMAIN_NAME", f"{ip}.local")
|
||||
env.setdefault("DEVICE_HOSTNAME", "umbrel")
|
||||
env.setdefault("APP_SEED", "pvemanager")
|
||||
else:
|
||||
env.setdefault("LOCAL_DOMAIN", "tipi.local") # стандартная переменная Runtipi
|
||||
return env
|
||||
|
||||
|
||||
def _push_file(ssh: SSHClient, vmid: int, content: str, dest: str) -> None:
|
||||
@@ -299,13 +495,14 @@ def poc_install(db: Session, spec: InstallSpec, on_step: StepCb = _noop) -> Inst
|
||||
)
|
||||
|
||||
on_step("Доставка docker-compose.yml и .env (pct push)...", 75)
|
||||
_push_file(ssh, vmid, _normalize_compose(spec.compose_yaml), f"{APP_DIR}/docker-compose.yml")
|
||||
normalized, pub_port = _normalize_compose(spec.compose_yaml, spec.port,
|
||||
source=spec.source)
|
||||
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 = dict(spec.env_vars)
|
||||
env.setdefault("APP_DOMAIN", ip)
|
||||
env.setdefault("APP_HOST", ip)
|
||||
env.setdefault("LOCAL_DOMAIN", "tipi.local") # стандартная переменная Runtipi
|
||||
env["APP_IP"] = 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())
|
||||
@@ -347,7 +544,8 @@ 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) -> str:
|
||||
on_step: StepCb = _noop, health_timeout: int = 300,
|
||||
source: str = "runtipi") -> str:
|
||||
"""Обновление приложения в существующем контейнере (M4): доставка нового
|
||||
docker-compose.yml → docker compose pull && up -d → health-check.
|
||||
|
||||
@@ -365,12 +563,11 @@ def poc_update(server, client, vmid: int, node: str, compose_yaml: str,
|
||||
f"docker network create {TIPI_NETWORK}"),
|
||||
what="ensure docker network",
|
||||
)
|
||||
_push_file(ssh, vmid, _normalize_compose(compose_yaml), f"{APP_DIR}/docker-compose.yml")
|
||||
env = dict(env_vars)
|
||||
env.setdefault("APP_DOMAIN", ip)
|
||||
env.setdefault("APP_HOST", ip)
|
||||
env.setdefault("LOCAL_DOMAIN", "tipi.local") # стандартная переменная Runtipi
|
||||
env["APP_IP"] = ip
|
||||
normalized, pub_port = _normalize_compose(compose_yaml, port, source=source)
|
||||
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")
|
||||
|
||||
@@ -77,6 +77,18 @@ class Settings(BaseSettings):
|
||||
# (митигация риска смены формата Runtipi, ТЗ п.13); дефолт master — «всегда свежий».
|
||||
RUNTIPI_APPSTORE_REPO: str = Field(default="runtipi/runtipi-appstore", env="RUNTIPI_APPSTORE_REPO")
|
||||
RUNTIPI_APPSTORE_REF: str = Field(default="master", env="RUNTIPI_APPSTORE_REF")
|
||||
# Дополнительный источник каталога — Umbrel (getumbrel/umbrel-apps). Формат
|
||||
# манифеста иной (umbrel-app.yml + docker-compose.yml), поэтому подключается
|
||||
# отдельным провайдером; см. UmbrelCatalogProvider.
|
||||
UMBREL_APPSTORE_REPO: str = Field(default="getumbrel/umbrel-apps", env="UMBREL_APPSTORE_REPO")
|
||||
UMBREL_APPSTORE_REF: str = Field(default="master", env="UMBREL_APPSTORE_REF")
|
||||
# Иконки Umbrel-приложений лежат не в umbrel-apps, а в отдельном репозитории
|
||||
# галереи (<app-id>/icon.svg). Скачивается одним tarball при синхронизации.
|
||||
UMBREL_APPSTORE_GALLERY_REPO: str = Field(
|
||||
default="getumbrel/umbrel-apps-gallery", env="UMBREL_APPSTORE_GALLERY_REPO")
|
||||
# Активные источники каталога (через запятую): "runtipi", "umbrel" или оба.
|
||||
# По умолчанию только runtipi — Umbrel включается явно.
|
||||
APPSTORE_SOURCES: str = Field(default="runtipi", env="APPSTORE_SOURCES")
|
||||
# Каталог для кэша логотипов/данных App Store (монтируемый volume — переживает пересборку).
|
||||
APPSTORE_DATA_DIR: str = Field(default="/app/data/appstore", env="APPSTORE_DATA_DIR")
|
||||
# Периодичность авто-синхронизации каталога (ТЗ F-CAT-1).
|
||||
|
||||
@@ -19,10 +19,16 @@ class CatalogApp(Base):
|
||||
"""
|
||||
__tablename__ = "catalog_apps"
|
||||
|
||||
# id приложения из runtipi (config.json → "id"), напр. "uptime-kuma"
|
||||
# id приложения из runtipi (config.json → "id"), напр. "uptime-kuma".
|
||||
# Для источников кроме runtipi app_id префиксуется ("umbrel-<id>"), чтобы
|
||||
# не было коллизий PK между каталогами разных источников.
|
||||
app_id = Column(String(100), primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
|
||||
# источник каталога: "runtipi" | "umbrel". Определяет адаптацию compose
|
||||
# при установке (публикация порта, вырезание рантайм-сервисов источника).
|
||||
source = Column(String(50), nullable=False, default="runtipi", index=True)
|
||||
|
||||
# version — строка тега docker-образа ("2", "1.23.1"); tipi_version — целочисленный
|
||||
# инкремент версии рецепта (используется для update_available в M2).
|
||||
version = Column(String(50), nullable=True)
|
||||
@@ -57,6 +63,7 @@ class CatalogApp(Base):
|
||||
data = {
|
||||
"app_id": self.app_id,
|
||||
"name": self.name,
|
||||
"source": self.source or "runtipi",
|
||||
"version": self.version,
|
||||
"tipi_version": self.tipi_version,
|
||||
"categories": self.categories or [],
|
||||
|
||||
@@ -19,7 +19,13 @@ class IPAMNetwork(Base):
|
||||
proxmox_server_id = Column(Integer, nullable=True, index=True)
|
||||
proxmox_node = Column(String(100), nullable=True)
|
||||
proxmox_bridge = Column(String(20), nullable=True)
|
||||
|
||||
|
||||
# Привязка сети к рабочей области (workspace). Одна общая подсеть на область:
|
||||
# сеть/пул выдаются любому узлу области, без привязки к конкретной ноде.
|
||||
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
# Сеть по умолчанию внутри области — автоматически выбирается в мастерах.
|
||||
is_default = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
@@ -49,8 +49,9 @@ def _logos_dir() -> Path:
|
||||
|
||||
class AppRaw:
|
||||
"""Сырые файлы одного приложения из репозитория."""
|
||||
def __init__(self, app_id: str):
|
||||
def __init__(self, app_id: str, source: str = "runtipi"):
|
||||
self.app_id = app_id
|
||||
self.source = source
|
||||
self.config: Optional[dict] = None
|
||||
self.compose: Optional[str] = None
|
||||
self.description_md: Optional[str] = None
|
||||
@@ -61,18 +62,16 @@ class AppRaw:
|
||||
# ── Провайдер источника (изоляция, риск п.13) ─────────────────────────────────
|
||||
|
||||
class CatalogProvider(ABC):
|
||||
# источник, к которому относится провайдер (для scoping'а в sync_catalog)
|
||||
source: str = "runtipi"
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self) -> Dict[str, AppRaw]:
|
||||
"""Вернуть словарь app_id → AppRaw. Может бросить при сетевой ошибке."""
|
||||
|
||||
|
||||
class RuntipiCatalogProvider(CatalogProvider):
|
||||
def __init__(self, repo: Optional[str] = None, ref: Optional[str] = None):
|
||||
self.repo = repo or settings.RUNTIPI_APPSTORE_REPO
|
||||
self.ref = ref or settings.RUNTIPI_APPSTORE_REF
|
||||
|
||||
def _download(self) -> Path:
|
||||
url = f"https://github.com/{self.repo}/archive/{self.ref}.tar.gz"
|
||||
@staticmethod
|
||||
def _download_tarball(repo: str, ref: str) -> Path:
|
||||
url = f"https://github.com/{repo}/archive/{ref}.tar.gz"
|
||||
logger.info(f"[appstore] downloading catalog tarball: {url}")
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz")
|
||||
try:
|
||||
@@ -84,6 +83,22 @@ class RuntipiCatalogProvider(CatalogProvider):
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
@staticmethod
|
||||
def _read(tar: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
|
||||
f = tar.extractfile(member)
|
||||
return f.read() if f else b""
|
||||
|
||||
|
||||
class RuntipiCatalogProvider(CatalogProvider):
|
||||
source = "runtipi"
|
||||
|
||||
def __init__(self, repo: Optional[str] = None, ref: Optional[str] = None):
|
||||
self.repo = repo or settings.RUNTIPI_APPSTORE_REPO
|
||||
self.ref = ref or settings.RUNTIPI_APPSTORE_REF
|
||||
|
||||
def _download(self) -> Path:
|
||||
return self._download_tarball(self.repo, self.ref)
|
||||
|
||||
def fetch(self) -> Dict[str, AppRaw]:
|
||||
archive = self._download()
|
||||
apps: Dict[str, AppRaw] = {}
|
||||
@@ -101,7 +116,7 @@ class RuntipiCatalogProvider(CatalogProvider):
|
||||
continue
|
||||
app_id = parts[ai + 1]
|
||||
rel = parts[ai + 2:] # путь внутри apps/<id>/
|
||||
raw = apps.setdefault(app_id, AppRaw(app_id))
|
||||
raw = apps.setdefault(app_id, AppRaw(app_id, source=self.source))
|
||||
fname = rel[-1].lower()
|
||||
|
||||
try:
|
||||
@@ -123,10 +138,119 @@ class RuntipiCatalogProvider(CatalogProvider):
|
||||
pass
|
||||
return apps
|
||||
|
||||
|
||||
class UmbrelCatalogProvider(CatalogProvider):
|
||||
"""Провайдер каталога Umbrel (getumbrel/umbrel-apps).
|
||||
|
||||
Структура репозитория: `<repo>-<ref>/<app-id>/{umbrel-app.yml,
|
||||
docker-compose.yml, <logo>}`. Метаданные лежат в umbrel-app.yml (а не в
|
||||
compose, как у Runtipi), поэтому нормализуем их в тот же shape, что ждёт
|
||||
_build_fields. app_id префиксуется "umbrel-" для изоляции PK от Runtipi.
|
||||
"""
|
||||
source = "umbrel"
|
||||
PREFIX = "umbrel-"
|
||||
|
||||
def __init__(self, repo: Optional[str] = None, ref: Optional[str] = None,
|
||||
gallery_repo: Optional[str] = None):
|
||||
self.repo = repo or settings.UMBREL_APPSTORE_REPO
|
||||
self.ref = ref or settings.UMBREL_APPSTORE_REF
|
||||
self.gallery_repo = gallery_repo or settings.UMBREL_APPSTORE_GALLERY_REPO
|
||||
|
||||
def fetch(self) -> Dict[str, AppRaw]:
|
||||
archive = self._download_tarball(self.repo, self.ref)
|
||||
apps: Dict[str, AppRaw] = {}
|
||||
try:
|
||||
with tarfile.open(archive, mode="r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile():
|
||||
continue
|
||||
# путь вида "<repo>-<ref>/<app-id>/<file>"
|
||||
parts = member.name.split("/")
|
||||
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()
|
||||
raw = apps.setdefault(app_id, AppRaw(app_id, source=self.source))
|
||||
try:
|
||||
if fname == "umbrel-app.yml":
|
||||
raw.config = self._map_manifest(
|
||||
raw_id, yaml.safe_load(self._read(tar, member)))
|
||||
elif fname == "docker-compose.yml":
|
||||
raw.compose = self._read(tar, member).decode("utf-8", "replace")
|
||||
elif fname in _LOGO_NAMES:
|
||||
raw.logo_bytes = self._read(tar, member)
|
||||
raw.logo_ext = os.path.splitext(fname)[1] or ".jpg"
|
||||
except Exception as e:
|
||||
logger.warning(f"[appstore] parse umbrel {app_id}/{fname}: {e}")
|
||||
finally:
|
||||
try:
|
||||
archive.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Иконки — из отдельного репозитория галереи (<app-id>/icon.svg). Сбой
|
||||
# получения иконок не должен ронять синк каталога.
|
||||
try:
|
||||
icons = self._fetch_gallery_icons()
|
||||
for app_id, raw in apps.items():
|
||||
data = icons.get(app_id[len(self.PREFIX):])
|
||||
if data:
|
||||
raw.logo_bytes = data
|
||||
raw.logo_ext = ".svg"
|
||||
except Exception as e:
|
||||
logger.warning(f"[appstore] umbrel gallery icons failed: {e}")
|
||||
|
||||
return apps
|
||||
|
||||
def _fetch_gallery_icons(self) -> Dict[str, bytes]:
|
||||
"""Скачать tarball галереи Umbrel и вернуть словарь raw_id → bytes(icon.svg)."""
|
||||
archive = self._download_tarball(self.gallery_repo, self.ref)
|
||||
icons: Dict[str, bytes] = {}
|
||||
try:
|
||||
with tarfile.open(archive, mode="r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile():
|
||||
continue
|
||||
# путь вида "<repo>-<ref>/<app-id>/icon.svg"
|
||||
parts = member.name.split("/")
|
||||
if len(parts) == 3 and parts[2].lower() == "icon.svg":
|
||||
icons[parts[1]] = self._read(tar, member)
|
||||
finally:
|
||||
try:
|
||||
archive.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return icons
|
||||
|
||||
@staticmethod
|
||||
def _read(tar: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
|
||||
f = tar.extractfile(member)
|
||||
return f.read() if f else b""
|
||||
def _map_manifest(raw_id: str, meta: Optional[dict]) -> Optional[dict]:
|
||||
"""umbrel-app.yml → config-словарь в формате, ожидаемом _build_fields."""
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
cat = meta.get("category")
|
||||
categories = [str(cat)] if cat else []
|
||||
port = meta.get("port")
|
||||
return {
|
||||
"name": meta.get("name") or raw_id,
|
||||
"version": str(meta.get("version")) if meta.get("version") is not None else None,
|
||||
# у Umbrel нет целочисленной версии рецепта — update_available по
|
||||
# tipi_version для umbrel-приложений не вычисляется (см. _refresh_update_flags).
|
||||
"tipi_version": None,
|
||||
"categories": categories,
|
||||
"short_desc": meta.get("tagline"),
|
||||
"description": meta.get("description"),
|
||||
"port": port if isinstance(port, int) else None,
|
||||
"form_fields": [], # Umbrel не описывает поля формы декларативно
|
||||
"supported_architectures": [], # обычно multi-arch — не ограничиваем
|
||||
"available": True,
|
||||
"deprecated": False,
|
||||
"dynamic_config": False,
|
||||
"source": meta.get("repo") or meta.get("website"),
|
||||
"author": meta.get("developer") or meta.get("submitter"),
|
||||
}
|
||||
|
||||
|
||||
# ── Синхронизация ─────────────────────────────────────────────────────────────
|
||||
@@ -171,6 +295,7 @@ def _build_fields(app_id: str, raw: AppRaw) -> Optional[dict]:
|
||||
reason = f"нет поддержки арх. {_host_arch()}"
|
||||
|
||||
return {
|
||||
"source": raw.source or "runtipi",
|
||||
"name": cfg.get("name") or app_id,
|
||||
"version": str(cfg.get("version")) if cfg.get("version") is not None else None,
|
||||
"tipi_version": cfg.get("tipi_version") if isinstance(cfg.get("tipi_version"), int) else None,
|
||||
@@ -190,55 +315,90 @@ def _build_fields(app_id: str, raw: AppRaw) -> Optional[dict]:
|
||||
}
|
||||
|
||||
|
||||
def _enabled_providers(ref: Optional[str] = None) -> List[CatalogProvider]:
|
||||
"""Список провайдеров по настройке APPSTORE_SOURCES."""
|
||||
raw = (settings.APPSTORE_SOURCES or "runtipi")
|
||||
names = [s.strip().lower() for s in raw.split(",") if s.strip()]
|
||||
providers: List[CatalogProvider] = []
|
||||
for n in names:
|
||||
if n == "runtipi":
|
||||
providers.append(RuntipiCatalogProvider(ref=ref))
|
||||
elif n == "umbrel":
|
||||
# ref относится к runtipi (совместимость со старой сигнатурой) — Umbrel
|
||||
# берёт собственный UMBREL_APPSTORE_REF.
|
||||
providers.append(UmbrelCatalogProvider())
|
||||
else:
|
||||
logger.warning(f"[appstore] unknown catalog source '{n}', пропущен")
|
||||
if not providers:
|
||||
providers.append(RuntipiCatalogProvider(ref=ref))
|
||||
return providers
|
||||
|
||||
|
||||
def sync_catalog(db: Session, ref: Optional[str] = None,
|
||||
provider: Optional[CatalogProvider] = None) -> dict:
|
||||
"""Идемпотентная синхронизация каталога. Возвращает статистику."""
|
||||
provider = provider or RuntipiCatalogProvider(ref=ref)
|
||||
"""Идемпотентная синхронизация каталога из всех активных источников.
|
||||
|
||||
Каждый источник синхронизируется независимо: сетевой сбой одного не отменяет
|
||||
остальные, а пометка «исчезнувших» приложений scoped'ится по source, чтобы
|
||||
синк runtipi не задел записи umbrel и наоборот.
|
||||
"""
|
||||
providers = [provider] if provider is not None else _enabled_providers(ref)
|
||||
started = _utcnow()
|
||||
apps = provider.fetch() # может бросить сетевую ошибку — обрабатывает вызывающий
|
||||
|
||||
created = updated = skipped = errors = 0
|
||||
seen: List[str] = []
|
||||
created = updated = skipped = errors = disappeared = 0
|
||||
source_errors: List[str] = []
|
||||
|
||||
for app_id, raw in apps.items():
|
||||
for prov in providers:
|
||||
try:
|
||||
fields = _build_fields(app_id, raw)
|
||||
if fields is None:
|
||||
skipped += 1
|
||||
continue
|
||||
fields["logo_path"] = _save_logo(app_id, raw)
|
||||
fields["synced_at"] = _utcnow()
|
||||
apps = prov.fetch()
|
||||
except Exception as e: # NF-4 — источник недоступен, не роняем остальные
|
||||
source_errors.append(f"{prov.source}: {e}")
|
||||
logger.warning(f"[appstore] fetch source '{prov.source}' failed: {e}")
|
||||
continue
|
||||
|
||||
existing = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
|
||||
if existing:
|
||||
for k, v in fields.items():
|
||||
if k == "logo_path" and v is None:
|
||||
continue # не затирать существующий логотип, если в этот раз не пришёл
|
||||
setattr(existing, k, v)
|
||||
updated += 1
|
||||
else:
|
||||
db.add(CatalogApp(app_id=app_id, **fields))
|
||||
created += 1
|
||||
seen.append(app_id)
|
||||
db.commit()
|
||||
except Exception as e: # F-CAT-5 — не роняем весь синк
|
||||
errors += 1
|
||||
db.rollback()
|
||||
logger.warning(f"[appstore] sync {app_id} failed: {e}")
|
||||
seen: List[str] = []
|
||||
for app_id, raw in apps.items():
|
||||
try:
|
||||
fields = _build_fields(app_id, raw)
|
||||
if fields is None:
|
||||
skipped += 1
|
||||
continue
|
||||
fields["logo_path"] = _save_logo(app_id, raw)
|
||||
fields["synced_at"] = _utcnow()
|
||||
|
||||
# Приложения, исчезнувшие из источника → пометить недоступными (не удаляем — M2 installed_apps)
|
||||
disappeared = 0
|
||||
if seen:
|
||||
disappeared = (
|
||||
db.query(CatalogApp)
|
||||
.filter(~CatalogApp.app_id.in_(seen), CatalogApp.available.is_(True))
|
||||
.update(
|
||||
{CatalogApp.available: False,
|
||||
CatalogApp.unavailable_reason: "удалено из источника"},
|
||||
synchronize_session=False,
|
||||
existing = db.query(CatalogApp).filter(CatalogApp.app_id == app_id).first()
|
||||
if existing:
|
||||
for k, v in fields.items():
|
||||
if k == "logo_path" and v is None:
|
||||
continue # не затирать существующий логотип
|
||||
setattr(existing, k, v)
|
||||
updated += 1
|
||||
else:
|
||||
db.add(CatalogApp(app_id=app_id, **fields))
|
||||
created += 1
|
||||
seen.append(app_id)
|
||||
db.commit()
|
||||
except Exception as e: # F-CAT-5 — не роняем весь синк
|
||||
errors += 1
|
||||
db.rollback()
|
||||
logger.warning(f"[appstore] sync {app_id} failed: {e}")
|
||||
|
||||
# Исчезнувшие из ЭТОГО источника → недоступны (scoped по source).
|
||||
if seen:
|
||||
disappeared += (
|
||||
db.query(CatalogApp)
|
||||
.filter(
|
||||
CatalogApp.source == prov.source,
|
||||
~CatalogApp.app_id.in_(seen),
|
||||
CatalogApp.available.is_(True),
|
||||
)
|
||||
.update(
|
||||
{CatalogApp.available: False,
|
||||
CatalogApp.unavailable_reason: "удалено из источника"},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.commit()
|
||||
|
||||
# F-CAT-4: выставить update_available для установленных приложений
|
||||
flagged = _refresh_update_flags(db)
|
||||
@@ -247,6 +407,8 @@ def sync_catalog(db: Session, ref: Optional[str] = None,
|
||||
stats = {
|
||||
"created": created, "updated": updated, "skipped": skipped,
|
||||
"errors": errors, "disappeared": disappeared, "flagged": flagged, "total": total,
|
||||
"sources": [p.source for p in providers if p is not None],
|
||||
"source_errors": source_errors,
|
||||
"ref": (ref or settings.RUNTIPI_APPSTORE_REF),
|
||||
"duration_sec": round((_utcnow() - started).total_seconds(), 1),
|
||||
"synced_at": _utcnow().isoformat(),
|
||||
@@ -284,12 +446,16 @@ def get_catalog_meta(db: Session) -> dict:
|
||||
for (arr,) in db.query(CatalogApp.categories).all():
|
||||
for c in (arr or []):
|
||||
cats.add(c)
|
||||
sources = sorted(
|
||||
s for (s,) in db.query(CatalogApp.source).distinct().all() if s
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"last_synced_at": last[0].isoformat() if last and last[0] else None,
|
||||
"repo": settings.RUNTIPI_APPSTORE_REPO,
|
||||
"ref": settings.RUNTIPI_APPSTORE_REF,
|
||||
"categories": sorted(cats),
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from ..appstore.pipeline import (
|
||||
APP_DIR, APP_DATA_DIR, InstallSpec, PocError, _NAME_RE, _node_ssh, _shq,
|
||||
poc_install, poc_teardown, poc_update,
|
||||
)
|
||||
from ..api.proxmox._helpers import _get_proxmox_client, get_next_vmid
|
||||
from ..api.proxmox._helpers import _get_proxmox_client, get_next_vmid, soft_delete_vm_instance
|
||||
from ..config import settings
|
||||
from ..db import SessionLocal
|
||||
from ..ipam_service import IPAMService
|
||||
@@ -175,6 +175,7 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
|
||||
server_id: int, node: Optional[str] = None, cores: int = 2,
|
||||
memory: int = 2048, disk: int = 8, storage: str = "local-lvm",
|
||||
bridge: str = "vmbr0", ostemplate: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
ipam_network_id: Optional[int] = None,
|
||||
ipam_pool_id: Optional[int] = None) -> dict:
|
||||
"""Синхронная часть: валидация + запись в БД. Пайплайн — в пуле потоков."""
|
||||
@@ -196,7 +197,15 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
|
||||
if not server:
|
||||
raise EngineError("Proxmox сервер не найден")
|
||||
|
||||
# Кастомный порт из формы имеет приоритет над портом каталога (app.port).
|
||||
if port is not None and not (1 <= port <= 65535):
|
||||
raise EngineError("Порт должен быть в диапазоне 1..65535")
|
||||
effective_port = port if port else app.port
|
||||
|
||||
env, secrets_shown = build_env(app, form_answers)
|
||||
# APP_PORT в окружении должен соответствовать выбранному порту.
|
||||
if port:
|
||||
env["APP_PORT"] = str(port)
|
||||
vmid = get_next_vmid(db, server_id)
|
||||
|
||||
# IPAM (опционально): выделяем адрес до старта пайплайна, чтобы ошибка
|
||||
@@ -214,7 +223,7 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
|
||||
app_id=app_id, server_id=server_id, vmid=vmid, node=node, name=name,
|
||||
version_installed=str(app.version) if app.version is not None else None,
|
||||
tipi_version_installed=app.tipi_version,
|
||||
env_encrypted=json.dumps(env), ip=static_ip, port=app.port, status="installing",
|
||||
env_encrypted=json.dumps(env), ip=static_ip, port=effective_port, status="installing",
|
||||
owner_id=user.id,
|
||||
)
|
||||
db.add(ia)
|
||||
@@ -225,8 +234,8 @@ def install(db, user, *, app_id: str, name: str, form_answers: dict,
|
||||
|
||||
_engine_executor.submit(
|
||||
_run_install, ia.id, op.id, user.id, app.compose_yaml, env, golden,
|
||||
cores, memory, disk, server_id, node, name, app.port, ia.version_installed, vmid,
|
||||
storage, bridge, ip_config, static_ip,
|
||||
cores, memory, disk, server_id, node, name, effective_port, ia.version_installed, vmid,
|
||||
storage, bridge, ip_config, static_ip, app.source or "runtipi",
|
||||
)
|
||||
return {"installed_app_id": ia.id, "operation_id": op.id, "vmid": vmid, "secrets": secrets_shown}
|
||||
|
||||
@@ -244,7 +253,7 @@ def _new_operation(db, installed_app_id: int, op_type: str, user_id: int) -> App
|
||||
|
||||
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):
|
||||
storage, bridge, ip_config=None, static_ip=None, source="runtipi"):
|
||||
"""Тело установки в пуле потоков."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -253,6 +262,7 @@ def _run_install(installed_app_id, operation_id, user_id, compose_yaml, env, ost
|
||||
compose_yaml=compose_yaml, port=port or 80, 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",
|
||||
)
|
||||
|
||||
def on_step(step: str, progress: int) -> None:
|
||||
@@ -321,7 +331,7 @@ def retry_install(db, user, installed_app_id: int) -> dict:
|
||||
_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,
|
||||
"local-lvm", "vmbr0", None, ia.ip, (app.source or "runtipi"),
|
||||
)
|
||||
return {"installed_app_id": ia.id, "operation_id": op.id}
|
||||
|
||||
@@ -359,6 +369,12 @@ def _run_delete(installed_app_id, operation_id, user_id, server_id, vmid, node):
|
||||
reason=f"App Store: удаление приложения #{installed_app_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[appstore-engine] IPAM release vmid={vmid}: {e}")
|
||||
# Помечаем кэш-запись инстанса удалённой сразу, чтобы контейнер
|
||||
# исчез из списка Инстансов немедленно, не дожидаясь фонового воркера.
|
||||
try:
|
||||
soft_delete_vm_instance(db, server_id, vmid)
|
||||
except Exception as e:
|
||||
logger.warning(f"[appstore-engine] soft-delete vm_instance vmid={vmid}: {e}")
|
||||
|
||||
# финальное событие ДО удаления записи (каскад удалит app_operations)
|
||||
_finish_op(operation_id, user_id, "completed")
|
||||
@@ -428,13 +444,13 @@ 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 {},
|
||||
form_answers or {}, (app.source or "runtipi"),
|
||||
)
|
||||
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):
|
||||
new_version, new_tipi, form_answers, source="runtipi"):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ia = db.get(InstalledApp, installed_app_id)
|
||||
@@ -471,7 +487,7 @@ def _run_update(installed_app_id, operation_id, user_id, compose_yaml,
|
||||
|
||||
# 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)
|
||||
env, ia.port or 80, ia.ip, on_step, source=source or "runtipi")
|
||||
|
||||
if status == "running":
|
||||
ia.status = "running"
|
||||
|
||||
@@ -1521,6 +1521,21 @@ def migrate_appstore_catalog(conn):
|
||||
logger.info("✓ App Store catalog_apps table created")
|
||||
|
||||
|
||||
def migrate_appstore_catalog_source(conn):
|
||||
"""Add source column to catalog_apps (мультиисточник: runtipi | umbrel)."""
|
||||
if not table_exists(conn, 'catalog_apps'):
|
||||
return
|
||||
if column_exists(conn, 'catalog_apps', 'source'):
|
||||
return
|
||||
conn.execute(text(
|
||||
"ALTER TABLE catalog_apps ADD COLUMN source VARCHAR(50) NOT NULL DEFAULT 'runtipi'"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_catalog_apps_source ON catalog_apps (source)"
|
||||
))
|
||||
logger.info("✓ App Store catalog_apps.source column added")
|
||||
|
||||
|
||||
def migrate_appstore_installed(conn):
|
||||
"""Create installed_apps and app_operations tables for the App Engine (M2)."""
|
||||
if not table_exists(conn, 'installed_apps'):
|
||||
@@ -1573,6 +1588,47 @@ def migrate_appstore_installed(conn):
|
||||
add_column_if_not_exists(conn, 'installed_apps', 'snapshot_tipi_version', 'INTEGER')
|
||||
|
||||
|
||||
def migrate_ipam_network_workspace(conn):
|
||||
"""Migration 33: Bind IPAM networks to a workspace + default-network flag.
|
||||
|
||||
Adds ipam_networks.workspace_id (FK workspaces) and ipam_networks.is_default.
|
||||
Backfills workspace_id from the network's proxmox_server_id via workspace_servers
|
||||
(each server belongs to a workspace), so existing server-bound networks land in
|
||||
the right workspace automatically.
|
||||
"""
|
||||
if not table_exists(conn, 'ipam_networks'):
|
||||
logger.info("Table ipam_networks does not exist, skipping workspace migration")
|
||||
return
|
||||
|
||||
added = False
|
||||
if add_column_if_not_exists(conn, 'ipam_networks', 'workspace_id', 'INTEGER REFERENCES workspaces(id) ON DELETE SET NULL'):
|
||||
added = True
|
||||
conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ipam_network_workspace ON ipam_networks(workspace_id)"
|
||||
))
|
||||
if add_column_if_not_exists(conn, 'ipam_networks', 'is_default', 'BOOLEAN NOT NULL DEFAULT FALSE'):
|
||||
added = True
|
||||
|
||||
# Backfill workspace_id from server binding (only for rows not yet assigned).
|
||||
if table_exists(conn, 'workspace_servers'):
|
||||
try:
|
||||
conn.execute(text("""
|
||||
UPDATE ipam_networks n
|
||||
SET workspace_id = ws.workspace_id
|
||||
FROM workspace_servers ws
|
||||
WHERE n.workspace_id IS NULL
|
||||
AND n.proxmox_server_id IS NOT NULL
|
||||
AND ws.server_id = n.proxmox_server_id
|
||||
"""))
|
||||
except Exception as e:
|
||||
logger.debug(f"IPAM workspace backfill: {e}")
|
||||
|
||||
if added:
|
||||
logger.info("✓ Added workspace_id/is_default to ipam_networks")
|
||||
else:
|
||||
logger.info("✓ ipam_networks.workspace_id/is_default already exist")
|
||||
|
||||
|
||||
def run_all_migrations(engine, db_session=None):
|
||||
"""
|
||||
Run all migrations in order.
|
||||
@@ -1843,6 +1899,22 @@ def run_all_migrations(engine, db_session=None):
|
||||
logger.warning(f"App Store installed migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# Migration 33: IPAM network workspace binding + default flag
|
||||
try:
|
||||
migrate_ipam_network_workspace(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"IPAM network workspace migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# Migration 34: App Store catalog source column (runtipi | umbrel)
|
||||
try:
|
||||
migrate_appstore_catalog_source(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"App Store catalog source migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("✓ All migrations completed successfully")
|
||||
logger.info("=" * 50)
|
||||
|
||||
@@ -15,8 +15,9 @@ export default function AppStorePage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<string>('');
|
||||
const [source, setSource] = useState<string>('');
|
||||
|
||||
const { data: catalog, isLoading } = useCatalog(search, category);
|
||||
const { data: catalog, isLoading } = useCatalog(search, category, source);
|
||||
const { data: meta } = useCatalogMeta();
|
||||
const { data: installed = [] } = useInstalledApps();
|
||||
const sync = useSyncCatalog();
|
||||
@@ -82,6 +83,19 @@ export default function AppStorePage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(meta?.sources?.length ?? 0) > 1 && (
|
||||
<Select value={source} onValueChange={(v) => setSource(v ?? '')}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder={t('appstore.all_sources')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('appstore.all_sources')}</SelectItem>
|
||||
{(meta?.sources ?? []).map((s) => (
|
||||
<SelectItem key={s} value={s}>{t(`appstore.sources.${s}`, s)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{meta?.last_synced_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('appstore.last_synced')}: {new Date(meta.last_synced_at).toLocaleString()}
|
||||
@@ -124,6 +138,11 @@ export default function AppStorePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1">
|
||||
{(meta?.sources?.length ?? 0) > 1 && app.source && (
|
||||
<Badge variant="secondary" className="text-2xs capitalize">
|
||||
{t(`appstore.sources.${app.source}`, app.source)}
|
||||
</Badge>
|
||||
)}
|
||||
{(app.categories ?? []).slice(0, 2).map((c) => (
|
||||
<Badge key={c} variant="outline" className="text-2xs">{t(`appstore.categories.${c}`, c)}</Badge>
|
||||
))}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||
import { useLXCStorages } from '@/hooks/use-lxc-templates';
|
||||
import { useIPAMNetworks, useIPAMPools } from '@/hooks/use-ipam';
|
||||
import { scopedNetworks, autoPickNetworkId } from '@/features/ipam/network-scope';
|
||||
import { useWorkspaceStore } from '@/stores/workspace-store';
|
||||
import { useInstallApp, useInstalledApp, useOperationProgress, useAppOperations } from '@/hooks/use-appstore';
|
||||
import type { AppOperation, CatalogApp } from '@/types/appstore';
|
||||
|
||||
@@ -44,6 +45,8 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
const [disk, setDisk] = useState(8);
|
||||
const [storage, setStorage] = useState('local-lvm');
|
||||
const [bridge, setBridge] = useState('vmbr0');
|
||||
// Кастомный рабочий порт: по умолчанию берём из каталога (app.port).
|
||||
const [port, setPort] = useState<string>(app.port ? String(app.port) : '');
|
||||
const [ipamNetworkId, setIpamNetworkId] = useState<string>('');
|
||||
const [ipamPoolId, setIpamPoolId] = useState<string>('');
|
||||
|
||||
@@ -53,28 +56,24 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
const serverIdNum = serverId ? Number(serverId) : undefined;
|
||||
const { data: nodesData } = useNodes(serverIdNum ?? 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
// Сети, релевантные выбранным серверу/ноде (для выпадающего списка).
|
||||
const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId);
|
||||
// Сети активной рабочей области (для выпадающего списка).
|
||||
const availableNetworks = useMemo(
|
||||
() => scopedNetworks(ipamNetworks, serverIdNum, node || undefined),
|
||||
[ipamNetworks, serverIdNum, node],
|
||||
() => scopedNetworks(ipamNetworks, activeWorkspaceId),
|
||||
[ipamNetworks, activeWorkspaceId],
|
||||
);
|
||||
|
||||
// Смена сервера: сбрасываем ноду и выбираем сеть, привязанную к серверу (если есть).
|
||||
// Смена сервера: сбрасываем ноду и подставляем сеть по умолчанию активной области.
|
||||
const handleServerChange = (v: string) => {
|
||||
setServerId(v ?? '');
|
||||
setNode('');
|
||||
const nid = autoPickNetworkId(ipamNetworks, v ? Number(v) : undefined, undefined);
|
||||
const nid = autoPickNetworkId(ipamNetworks, activeWorkspaceId);
|
||||
setIpamNetworkId(nid ? String(nid) : '');
|
||||
setIpamPoolId('');
|
||||
};
|
||||
|
||||
// Смена ноды: автоматически выбираем сеть, из пула которой выдаётся IP.
|
||||
const handleNodeChange = (v: string) => {
|
||||
setNode(v);
|
||||
const nid = autoPickNetworkId(ipamNetworks, serverIdNum, v || undefined);
|
||||
setIpamNetworkId(nid ? String(nid) : '');
|
||||
setIpamPoolId('');
|
||||
};
|
||||
// Нода нужна только для размещения контейнера; на выбор сети больше не влияет.
|
||||
const handleNodeChange = (v: string) => setNode(v);
|
||||
|
||||
// Хранилища ноды под rootfs LXC (для выбора вместо ручного ввода).
|
||||
const { data: storages = [] } = useLXCStorages(
|
||||
@@ -128,6 +127,7 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
setName(app.app_id); setServerId(''); setNode(''); setAnswers({});
|
||||
setAdvanced(false); setCores(2); setMemory(2048); setDisk(8);
|
||||
setStorage('local-lvm'); setBridge('vmbr0');
|
||||
setPort(app.port ? String(app.port) : '');
|
||||
setIpamNetworkId(''); setIpamPoolId('');
|
||||
};
|
||||
|
||||
@@ -142,6 +142,7 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
{
|
||||
app_id: app.app_id, name, server_id: Number(serverId), node: node || undefined,
|
||||
form_answers: answers, cores, memory, disk, storage, bridge,
|
||||
port: port ? Number(port) : undefined,
|
||||
ipam_network_id: ipamNetworkId ? Number(ipamNetworkId) : undefined,
|
||||
ipam_pool_id: ipamPoolId ? Number(ipamPoolId) : undefined,
|
||||
},
|
||||
@@ -271,6 +272,15 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
|
||||
<Label>{t('appstore.bridge')}</Label>
|
||||
<Input value={bridge} onChange={(e) => setBridge(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('appstore.port')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={port}
|
||||
placeholder={app.port ? String(app.port) : t('appstore.port_auto')}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>{t('appstore.ipam_network')}</Label>
|
||||
<Select
|
||||
|
||||
@@ -12,7 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
||||
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
import { autoPickNetworkId } from '@/features/ipam/network-scope';
|
||||
import { scopedNetworks, autoPickNetworkId } from '@/features/ipam/network-scope';
|
||||
import { useWorkspaceStore } from '@/stores/workspace-store';
|
||||
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
||||
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
|
||||
import { useProfile, useMyQuota } from '@/hooks/use-settings';
|
||||
@@ -103,13 +104,10 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
const { data: vmStorages = [] } = useLXCStorages(selectedServer?.id, undefined, 'images');
|
||||
const { data: nodesData } = useNodes(selectedServer?.id ?? 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId);
|
||||
|
||||
// Смена ноды (VM): выбираем ноду и авто-подбираем IPAM-сеть, привязанную к ней.
|
||||
const handleVMNodeChange = (node: string) => {
|
||||
setSelectedNode(node);
|
||||
const nid = autoPickNetworkId(networks, selectedServer?.id, node || undefined);
|
||||
setConfig(p => ({ ...p, ipam_network_id: nid }));
|
||||
};
|
||||
// Нода нужна только для размещения; сеть подбирается по активной рабочей области.
|
||||
const handleVMNodeChange = (node: string) => setSelectedNode(node);
|
||||
|
||||
// ── SSH Keys & ownership ──────────────────────────────────────────────────
|
||||
const { data: profile } = useProfile();
|
||||
@@ -235,19 +233,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
};
|
||||
|
||||
// ── Filtered IPAM networks ────────────────────────────────────────────────
|
||||
// Show only networks matching the selected server (or global networks with no server assigned)
|
||||
// Further filter by node if a node is set
|
||||
const vmFilteredNetworks = networks.filter(n =>
|
||||
n.is_active &&
|
||||
(!n.proxmox_server_id || !selectedServer || n.proxmox_server_id === selectedServer.id) &&
|
||||
(!n.proxmox_node || !selectedNode || n.proxmox_node === selectedNode)
|
||||
);
|
||||
|
||||
const lxcFilteredNetworks = networks.filter(n =>
|
||||
n.is_active &&
|
||||
(!n.proxmox_server_id || !selectedServer || n.proxmox_server_id === selectedServer.id) &&
|
||||
(!n.proxmox_node || !selectedLXCTemplate?.node || n.proxmox_node === selectedLXCTemplate.node)
|
||||
);
|
||||
// Сети активной рабочей области (список уже отфильтрован бэкендом по X-Active-Workspace).
|
||||
const vmFilteredNetworks = scopedNetworks(networks, activeWorkspaceId);
|
||||
const lxcFilteredNetworks = vmFilteredNetworks;
|
||||
|
||||
// Apply template defaults when template selected
|
||||
const selectTemplate = (tpl: OSTemplate) => {
|
||||
@@ -290,8 +278,9 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
onClick={() => {
|
||||
setSelectedServer(srv); setSelectedLXCTemplate(null); setSelectedTemplate(null);
|
||||
setSelectedNode('');
|
||||
setConfig(p => ({ ...p, ipam_network_id: null }));
|
||||
setLxcConfig(p => ({ ...p, ipam_network_id: null }));
|
||||
const nid = autoPickNetworkId(networks, activeWorkspaceId);
|
||||
setConfig(p => ({ ...p, ipam_network_id: nid }));
|
||||
setLxcConfig(p => ({ ...p, ipam_network_id: nid }));
|
||||
}}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
@@ -410,7 +399,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
className={`cursor-pointer transition-colors hover:border-success/50 ${selectedLXCTemplate?.volid === tpl.volid ? 'border-success bg-success/5' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedLXCTemplate(tpl);
|
||||
const nid = autoPickNetworkId(networks, selectedServer?.id, tpl.node || undefined);
|
||||
const nid = autoPickNetworkId(networks, activeWorkspaceId);
|
||||
setLxcConfig(p => ({ ...p, ipam_network_id: nid }));
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
useUpdateNetwork, useDeleteNetwork, useCreatePool, useUpdatePool, useDeletePool, useIPAMIPMap,
|
||||
} from '@/hooks/use-ipam';
|
||||
import type { IPAMNetwork, IPAMPool } from '@/types';
|
||||
import { ServerNodeSelect } from './ServerNodeSelect';
|
||||
import { WorkspaceSelect } from './WorkspaceSelect';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
|
||||
@@ -73,8 +73,8 @@ export default function IPAMNetworkDetailPage() {
|
||||
<InfoRow label={t('common.gateway')} value={network.gateway || '—'} />
|
||||
<InfoRow label={t('common.vlan')} value={network.vlan_id ? String(network.vlan_id) : '—'} />
|
||||
<InfoRow label={t('common.dns')} value={network.dns_primary || '—'} />
|
||||
<InfoRow label={t('ipam.server')} value={network.server_name || '—'} />
|
||||
<InfoRow label={t('appstore.node')} value={network.proxmox_node || '—'} />
|
||||
<InfoRow label={t('ipam.workspace', 'Рабочая область')} value={network.workspace_name || t('ipam.workspace_global', '— глобальная —')} />
|
||||
<InfoRow label={t('ipam.default', 'по умолчанию')} value={network.is_default ? t('common.yes') : t('common.no')} />
|
||||
<InfoRow label={t('ipam.bridge')} value={network.proxmox_bridge || '—'} />
|
||||
{network.description && <InfoRow label={t('ipam.description')} value={network.description} />}
|
||||
</CardContent>
|
||||
@@ -140,8 +140,8 @@ function NetworkActions({ network }: { network: IPAMNetwork }) {
|
||||
dns_secondary: network.dns_secondary ?? '',
|
||||
dns_domain: network.dns_domain ?? '',
|
||||
proxmox_bridge: network.proxmox_bridge ?? '',
|
||||
proxmox_server_id: network.proxmox_server_id as number | undefined,
|
||||
proxmox_node: network.proxmox_node as string | undefined,
|
||||
workspace_id: network.workspace_id ?? undefined,
|
||||
is_default: !!network.is_default,
|
||||
is_active: network.is_active,
|
||||
description: network.description ?? '',
|
||||
});
|
||||
@@ -157,9 +157,9 @@ function NetworkActions({ network }: { network: IPAMNetwork }) {
|
||||
dns_secondary: form.dns_secondary || undefined,
|
||||
dns_domain: form.dns_domain || undefined,
|
||||
proxmox_bridge: form.proxmox_bridge || undefined,
|
||||
// null явно очищает привязку на бэкенде (undefined был бы исключён из update)
|
||||
proxmox_server_id: (form.proxmox_server_id ?? null) as number | undefined,
|
||||
proxmox_node: (form.proxmox_node ?? null) as string | undefined,
|
||||
// null явно очищает привязку области на бэкенде (undefined был бы исключён из update)
|
||||
workspace_id: (form.workspace_id ?? null) as number | undefined,
|
||||
is_default: form.is_default,
|
||||
is_active: form.is_active,
|
||||
description: form.description || undefined,
|
||||
},
|
||||
@@ -197,12 +197,12 @@ function NetworkActions({ network }: { network: IPAMNetwork }) {
|
||||
<Field label={`${t('common.dns')} 2`}><Input value={form.dns_secondary} onChange={e => setForm(p => ({ ...p, dns_secondary: e.target.value }))} /></Field>
|
||||
<Field label={t('ipam.dns_domain')}><Input value={form.dns_domain} onChange={e => setForm(p => ({ ...p, dns_domain: e.target.value }))} /></Field>
|
||||
<Field label={t('ipam.bridge')}><Input value={form.proxmox_bridge} onChange={e => setForm(p => ({ ...p, proxmox_bridge: e.target.value }))} placeholder="vmbr0" /></Field>
|
||||
<ServerNodeSelect
|
||||
serverId={form.proxmox_server_id}
|
||||
node={form.proxmox_node}
|
||||
onChange={({ serverId, node }) => setForm(p => ({ ...p, proxmox_server_id: serverId, proxmox_node: node }))}
|
||||
/>
|
||||
<WorkspaceSelect value={form.workspace_id} onChange={(id) => setForm(p => ({ ...p, workspace_id: id }))} />
|
||||
<Field label={t('ipam.description')} full><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></Field>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input type="checkbox" checked={form.is_default} onChange={e => setForm(p => ({ ...p, is_default: e.target.checked }))} />
|
||||
{t('ipam.is_default', 'Сеть по умолчанию для области')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input type="checkbox" checked={form.is_active} onChange={e => setForm(p => ({ ...p, is_active: e.target.checked }))} />
|
||||
{t('ipam.is_active')}
|
||||
|
||||
@@ -9,12 +9,12 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useIPAMNetworks, useCreateNetwork } from '@/hooks/use-ipam';
|
||||
import { ServerNodeSelect } from './ServerNodeSelect';
|
||||
import { WorkspaceSelect } from './WorkspaceSelect';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const emptyForm = {
|
||||
name: '', network: '', gateway: '', description: '', proxmox_bridge: '',
|
||||
proxmox_server_id: undefined as number | undefined, proxmox_node: undefined as string | undefined,
|
||||
workspace_id: undefined as number | undefined, is_default: false,
|
||||
};
|
||||
|
||||
export default function IPAMNetworksPage() {
|
||||
@@ -38,8 +38,8 @@ export default function IPAMNetworksPage() {
|
||||
gateway: form.gateway || undefined,
|
||||
description: form.description || undefined,
|
||||
proxmox_bridge: form.proxmox_bridge || undefined,
|
||||
proxmox_server_id: form.proxmox_server_id,
|
||||
proxmox_node: form.proxmox_node,
|
||||
workspace_id: form.workspace_id,
|
||||
is_default: form.is_default,
|
||||
}, {
|
||||
onSuccess: () => { toast.success(t('ipam.network_created')); setDialogOpen(false); setForm(emptyForm); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
@@ -84,7 +84,10 @@ export default function IPAMNetworksPage() {
|
||||
<span>{net.used_ips ?? 0} / {net.total_ips ?? 0} {t('ipam.ips_used')}</span>
|
||||
<span>{(net.utilization_percent ?? 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
{net.server_name && <p className="text-xs text-muted-foreground">{net.server_name}</p>}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{net.workspace_name && <span>{net.workspace_name}</span>}
|
||||
{net.is_default && <Badge variant="outline" className="text-2xs">{t('ipam.default', 'по умолчанию')}</Badge>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -99,13 +102,11 @@ export default function IPAMNetworksPage() {
|
||||
<div><Label>{t('common.name')}</Label><Input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
|
||||
<div><Label>CIDR</Label><Input value={form.network} onChange={e => setForm(p => ({ ...p, network: e.target.value }))} placeholder="10.0.0.0/24" /></div>
|
||||
<div><Label>Gateway</Label><Input value={form.gateway} onChange={e => setForm(p => ({ ...p, gateway: e.target.value }))} placeholder="10.0.0.1" /></div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ServerNodeSelect
|
||||
serverId={form.proxmox_server_id}
|
||||
node={form.proxmox_node}
|
||||
onChange={({ serverId, node }) => setForm(p => ({ ...p, proxmox_server_id: serverId, proxmox_node: node }))}
|
||||
/>
|
||||
</div>
|
||||
<WorkspaceSelect value={form.workspace_id} onChange={(id) => setForm(p => ({ ...p, workspace_id: id }))} />
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.is_default} onChange={e => setForm(p => ({ ...p, is_default: e.target.checked }))} />
|
||||
{t('ipam.is_default', 'Сеть по умолчанию для области')}
|
||||
</label>
|
||||
<div><Label>{t('ipam.bridge')}</Label><Input value={form.proxmox_bridge} onChange={e => setForm(p => ({ ...p, proxmox_bridge: e.target.value }))} placeholder="vmbr0" /></div>
|
||||
<div><Label>{t('ipam.description')}</Label><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></div>
|
||||
<Button onClick={handleCreate} disabled={!form.name || !form.network || createNet.isPending} className="w-full">{t('common.create')}</Button>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||
|
||||
const NONE = '__none__';
|
||||
|
||||
interface Props {
|
||||
serverId?: number;
|
||||
node?: string;
|
||||
onChange: (v: { serverId?: number; node?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пара селектов «Сервер Proxmox» + «Нода» для привязки IPAM-сети к конкретной ноде.
|
||||
* Смена сервера сбрасывает выбранную ноду. Ноды подтягиваются для выбранного сервера.
|
||||
*/
|
||||
export function ServerNodeSelect({ serverId, node, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: servers = [] } = useServers();
|
||||
const { data: nodesData } = useNodes(serverId ?? 0);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1 block">{t('ipam.server')}</Label>
|
||||
<Select
|
||||
value={serverId ? String(serverId) : NONE}
|
||||
onValueChange={(v) => onChange({ serverId: v === NONE ? undefined : Number(v), node: undefined })}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder={t('ipam.any_server', '— не привязано —')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t('ipam.any_server', '— не привязано —')}</SelectItem>
|
||||
{servers.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block">{t('appstore.node')}</Label>
|
||||
<Select
|
||||
value={node || NONE}
|
||||
onValueChange={(v) => onChange({ serverId, node: v && v !== NONE ? v : undefined })}
|
||||
disabled={!serverId}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder={t('ipam.any_node', '— любая нода —')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t('ipam.any_node', '— любая нода —')}</SelectItem>
|
||||
{nodes.map((n) => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
frontend/src/features/ipam/WorkspaceSelect.tsx
Normal file
33
frontend/src/features/ipam/WorkspaceSelect.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useWorkspaces } from '@/hooks/use-workspaces';
|
||||
|
||||
const NONE = '__none__';
|
||||
|
||||
interface Props {
|
||||
value?: number | null;
|
||||
onChange: (workspaceId: number | undefined) => void;
|
||||
}
|
||||
|
||||
/** Селект «Рабочая область» для привязки IPAM-сети. */
|
||||
export function WorkspaceSelect({ value, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: workspaces = [] } = useWorkspaces();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1 block">{t('ipam.workspace', 'Рабочая область')}</Label>
|
||||
<Select
|
||||
value={value ? String(value) : NONE}
|
||||
onValueChange={(v) => onChange(v === NONE ? undefined : Number(v))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder={t('ipam.workspace_global', '— глобальная —')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t('ipam.workspace_global', '— глобальная —')}</SelectItem>
|
||||
{workspaces.map((w) => <SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,37 @@
|
||||
import type { IPAMNetwork } from '@/types';
|
||||
|
||||
/**
|
||||
* Сети, релевантные выбранным серверу/ноде (для выпадающих списков в визардах).
|
||||
* Показываем активные сети, которые:
|
||||
* - привязаны к выбранному серверу ИЛИ глобальные (без привязки к серверу);
|
||||
* - привязаны к выбранной ноде ИЛИ без привязки к конкретной ноде.
|
||||
* Сети, релевантные для выпадающих списков в мастерах.
|
||||
* Список сетей уже отфильтрован бэкендом по активной рабочей области
|
||||
* (заголовок X-Active-Workspace), поэтому здесь достаточно отсеять неактивные.
|
||||
* Если явно передан workspaceId — дополнительно оставляем только сети этой
|
||||
* области плюс глобальные (без привязки).
|
||||
*/
|
||||
export function scopedNetworks(
|
||||
networks: IPAMNetwork[],
|
||||
serverId?: number | null,
|
||||
node?: string | null,
|
||||
workspaceId?: number | null,
|
||||
): IPAMNetwork[] {
|
||||
return networks.filter((n) =>
|
||||
n.is_active &&
|
||||
(!n.proxmox_server_id || !serverId || n.proxmox_server_id === serverId) &&
|
||||
(!n.proxmox_node || !node || n.proxmox_node === node),
|
||||
(!workspaceId || !n.workspace_id || n.workspace_id === workspaceId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Автовыбор сети исходя из выбранной ноды: возвращает id IPAM-сети, из пула
|
||||
* которой нужно выдать IP, либо null (тогда fallback на DHCP/ручной ввод).
|
||||
* Автовыбор сети для активной рабочей области: возвращает id IPAM-сети, из пула
|
||||
* которой выдаётся IP, либо null (тогда fallback на DHCP/ручной ввод).
|
||||
*
|
||||
* Правило (детерминированное):
|
||||
* 1. сеть, привязанная именно к этой ноде выбранного сервера;
|
||||
* 2. иначе — сеть, привязанная к серверу без привязки к ноде;
|
||||
* 3. иначе — null (глобальные сети автоматически НЕ выбираем, чтобы не выдать
|
||||
* адрес из чужой подсети).
|
||||
* Правило: среди сетей области — сеть с флагом «по умолчанию», иначе первая
|
||||
* сеть области. Глобальные сети (без области) автоматически не выбираем.
|
||||
*/
|
||||
export function autoPickNetworkId(
|
||||
networks: IPAMNetwork[],
|
||||
serverId?: number | null,
|
||||
node?: string | null,
|
||||
workspaceId?: number | null,
|
||||
): number | null {
|
||||
if (!serverId) return null;
|
||||
const active = networks.filter((n) => n.is_active && n.proxmox_server_id === serverId);
|
||||
if (node) {
|
||||
const nodeBound = active.find((n) => n.proxmox_node === node);
|
||||
if (nodeBound) return nodeBound.id;
|
||||
}
|
||||
const serverOnly = active.find((n) => !n.proxmox_node);
|
||||
return serverOnly ? serverOnly.id : null;
|
||||
const inWs = networks.filter((n) =>
|
||||
n.is_active && (workspaceId ? n.workspace_id === workspaceId : !!n.workspace_id),
|
||||
);
|
||||
if (!inWs.length) return null;
|
||||
const def = inWs.find((n) => n.is_default);
|
||||
return (def ?? inWs[0]).id;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
} from '@/types/appstore';
|
||||
|
||||
export const appstoreKeys = {
|
||||
catalog: (q?: string, category?: string) => ['appstore-catalog', q ?? '', category ?? ''] as const,
|
||||
catalog: (q?: string, category?: string, source?: string) =>
|
||||
['appstore-catalog', q ?? '', category ?? '', source ?? ''] as const,
|
||||
catalogMeta: ['appstore-catalog-meta'] as const,
|
||||
catalogApp: (id: string) => ['appstore-catalog-app', id] as const,
|
||||
installed: ['appstore-installed'] as const,
|
||||
@@ -18,14 +19,15 @@ export const appstoreKeys = {
|
||||
|
||||
// ── Каталог ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCatalog(q?: string, category?: string, availableOnly = false) {
|
||||
export function useCatalog(q?: string, category?: string, source?: string, availableOnly = false) {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (category) params.set('category', category);
|
||||
if (source) params.set('source', source);
|
||||
if (availableOnly) params.set('available_only', 'true');
|
||||
const qs = params.toString();
|
||||
return useQuery({
|
||||
queryKey: appstoreKeys.catalog(q, category),
|
||||
queryKey: appstoreKeys.catalog(q, category, source),
|
||||
queryFn: () => apiClient.get<CatalogListResponse>(`/api/appstore/catalog${qs ? `?${qs}` : ''}`),
|
||||
});
|
||||
}
|
||||
@@ -94,6 +96,7 @@ export interface InstallArgs {
|
||||
disk: number;
|
||||
storage: string;
|
||||
bridge: string;
|
||||
port?: number;
|
||||
ipam_network_id?: number;
|
||||
ipam_pool_id?: number;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
"subtitle": "Self-hosted apps, one-click install into an LXC container",
|
||||
"search": "Search apps…",
|
||||
"all_categories": "All categories",
|
||||
"all_sources": "All sources",
|
||||
"sources": {
|
||||
"runtipi": "Runtipi",
|
||||
"umbrel": "Umbrel"
|
||||
},
|
||||
"refresh_catalog": "Refresh catalog",
|
||||
"last_synced": "Last synced",
|
||||
"never": "never",
|
||||
@@ -102,6 +107,8 @@
|
||||
"node": "Node (optional)",
|
||||
"storage": "Storage",
|
||||
"bridge": "Bridge",
|
||||
"port": "Port",
|
||||
"port_auto": "Default",
|
||||
"ipam_network": "IPAM network (IP address)",
|
||||
"ipam_dhcp": "DHCP (default)",
|
||||
"ipam_pool": "IPAM pool",
|
||||
@@ -1185,6 +1192,10 @@
|
||||
"network_updated": "Network updated",
|
||||
"network_deleted": "Network deleted",
|
||||
"is_active": "Active",
|
||||
"workspace": "Workspace",
|
||||
"workspace_global": "— global —",
|
||||
"is_default": "Default network for workspace",
|
||||
"default": "default",
|
||||
"dns_domain": "DNS Domain",
|
||||
"add_pool": "Add Pool",
|
||||
"edit_pool": "Edit Pool",
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
"subtitle": "Self-hosted приложения, установка в один клик в LXC-контейнер",
|
||||
"search": "Поиск приложений…",
|
||||
"all_categories": "Все категории",
|
||||
"all_sources": "Все источники",
|
||||
"sources": {
|
||||
"runtipi": "Runtipi",
|
||||
"umbrel": "Umbrel"
|
||||
},
|
||||
"refresh_catalog": "Обновить каталог",
|
||||
"last_synced": "Обновлён",
|
||||
"never": "никогда",
|
||||
@@ -102,6 +107,8 @@
|
||||
"node": "Нода (опционально)",
|
||||
"storage": "Хранилище",
|
||||
"bridge": "Мост",
|
||||
"port": "Порт",
|
||||
"port_auto": "По умолчанию",
|
||||
"ipam_network": "Сеть IPAM (IP-адрес)",
|
||||
"ipam_dhcp": "DHCP (по умолчанию)",
|
||||
"ipam_pool": "Пул IPAM",
|
||||
@@ -1185,6 +1192,10 @@
|
||||
"network_updated": "Сеть обновлена",
|
||||
"network_deleted": "Сеть удалена",
|
||||
"is_active": "Активна",
|
||||
"workspace": "Рабочая область",
|
||||
"workspace_global": "— глобальная —",
|
||||
"is_default": "Сеть по умолчанию для области",
|
||||
"default": "по умолчанию",
|
||||
"dns_domain": "DNS-домен",
|
||||
"add_pool": "Добавить пул",
|
||||
"edit_pool": "Изменить пул",
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface FormField {
|
||||
export interface CatalogAppLight {
|
||||
app_id: string;
|
||||
name: string;
|
||||
source: 'runtipi' | 'umbrel' | string;
|
||||
version: string | null;
|
||||
tipi_version: number | null;
|
||||
categories: string[];
|
||||
@@ -55,6 +56,7 @@ export interface CatalogMeta {
|
||||
repo: string;
|
||||
ref: string;
|
||||
categories: string[];
|
||||
sources?: string[];
|
||||
}
|
||||
|
||||
export type InstalledStatus =
|
||||
|
||||
@@ -340,6 +340,8 @@ export interface IPAMNetwork {
|
||||
proxmox_server_id?: number;
|
||||
proxmox_node?: string;
|
||||
proxmox_bridge?: string;
|
||||
workspace_id?: number | null;
|
||||
is_default?: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -348,6 +350,7 @@ export interface IPAMNetwork {
|
||||
available_ips?: number;
|
||||
utilization_percent?: number;
|
||||
server_name?: string;
|
||||
workspace_name?: string;
|
||||
}
|
||||
|
||||
export interface IPAMPool {
|
||||
|
||||
Reference in New Issue
Block a user