feat(appstore): кастомный порт при установке + мгновенная чистка инстанса
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 57s
Deploy to Production / Trigger production update (push) Successful in 7s

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:
markmorado
2026-07-09 01:20:28 +05:00
parent bbaae7c2b1
commit 633f1a424c
24 changed files with 821 additions and 259 deletions

View File

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

View File

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

View File

@@ -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_(

View File

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

View File

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

View File

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

View File

@@ -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 [],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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": "Изменить пул",

View File

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

View File

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