Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5988af6a3 | ||
|
|
c5189d88fe | ||
|
|
ac843da4ca | ||
|
|
f6b7eb2dae | ||
|
|
8ede0f0a3c | ||
|
|
8040973227 |
+1
-1
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="NetBird MSP Appliance",
|
title="NetBird MSP Appliance",
|
||||||
description="Multi-tenant NetBird management platform for MSPs",
|
description="Multi-tenant NetBird management platform for MSPs",
|
||||||
version="1.0.0",
|
version="1.1.2",
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Monitoring API — system overview, customer statuses, host resources."""
|
"""Monitoring API — system overview, customer statuses, host resources."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import platform
|
import platform
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
@@ -16,6 +18,13 @@ from app.services import docker_service, image_service
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Short-lived cache for the local update-status badges. This endpoint is
|
||||||
|
# triggered on every customer-table render (i.e. every search keystroke), but
|
||||||
|
# the underlying data (which images are outdated) only changes after an image
|
||||||
|
# pull + container recreate, so a few seconds of staleness is harmless.
|
||||||
|
_update_status_cache: dict[str, Any] = {"data": None, "expires": 0.0}
|
||||||
|
_UPDATE_STATUS_TTL_SECONDS = 20
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def system_status(
|
async def system_status(
|
||||||
@@ -58,8 +67,7 @@ async def all_customers_status(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
async def _build_entry(c: Customer) -> dict[str, Any]:
|
||||||
for c in customers:
|
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
"name": c.name,
|
"name": c.name,
|
||||||
@@ -67,7 +75,7 @@ async def all_customers_status(
|
|||||||
"status": c.status,
|
"status": c.status,
|
||||||
}
|
}
|
||||||
if c.deployment:
|
if c.deployment:
|
||||||
containers = docker_service.get_container_status(c.deployment.container_prefix)
|
containers = await docker_service.get_container_status_async(c.deployment.container_prefix)
|
||||||
entry["deployment_status"] = c.deployment.deployment_status
|
entry["deployment_status"] = c.deployment.deployment_status
|
||||||
entry["containers"] = containers
|
entry["containers"] = containers
|
||||||
entry["relay_udp_port"] = c.deployment.relay_udp_port
|
entry["relay_udp_port"] = c.deployment.relay_udp_port
|
||||||
@@ -76,9 +84,11 @@ async def all_customers_status(
|
|||||||
else:
|
else:
|
||||||
entry["deployment_status"] = None
|
entry["deployment_status"] = None
|
||||||
entry["containers"] = []
|
entry["containers"] = []
|
||||||
results.append(entry)
|
return entry
|
||||||
|
|
||||||
return results
|
# Fetch container status for all customers concurrently instead of one
|
||||||
|
# blocking Docker SDK call at a time.
|
||||||
|
return await asyncio.gather(*[_build_entry(c) for c in customers])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/resources")
|
@router.get("/resources")
|
||||||
@@ -205,15 +215,28 @@ async def customers_local_update_status(
|
|||||||
|
|
||||||
Compares running container image IDs against locally stored images.
|
Compares running container image IDs against locally stored images.
|
||||||
No network call — safe to call on every dashboard load.
|
No network call — safe to call on every dashboard load.
|
||||||
|
|
||||||
|
Results are cached for a few seconds since this is triggered on every
|
||||||
|
customer-table render (including every search keystroke) but the
|
||||||
|
underlying data rarely changes.
|
||||||
"""
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
if _update_status_cache["data"] is not None and now < _update_status_cache["expires"]:
|
||||||
|
return _update_status_cache["data"]
|
||||||
|
|
||||||
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
if not config:
|
if not config:
|
||||||
return []
|
return []
|
||||||
deployments = db.query(Deployment).all()
|
deployments = db.query(Deployment).all()
|
||||||
results = []
|
|
||||||
for dep in deployments:
|
async def _check(dep: Deployment) -> dict[str, Any]:
|
||||||
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
|
cs = await image_service.get_customer_container_image_status_async(dep.container_prefix, config)
|
||||||
results.append({"customer_id": dep.customer_id, "needs_update": cs["needs_update"]})
|
return {"customer_id": dep.customer_id, "needs_update": cs["needs_update"]}
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[_check(dep) for dep in deployments])
|
||||||
|
results = list(results)
|
||||||
|
_update_status_cache["data"] = results
|
||||||
|
_update_status_cache["expires"] = now + _UPDATE_STATUS_TTL_SECONDS
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+39
-10
@@ -4,6 +4,7 @@ There is no .env file. Every setting lives in the ``system_config`` table
|
|||||||
(singleton row with id=1) and is editable via the Web UI settings page.
|
(singleton row with id=1) and is editable via the Web UI settings page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -358,13 +359,15 @@ async def trigger_update(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Backup the database, git pull the latest code, and rebuild the container.
|
"""Kick off backup + git pull + container rebuild in the background.
|
||||||
|
|
||||||
|
Returns immediately — the actual work (which can take several minutes,
|
||||||
|
especially the ``--no-cache`` image build) runs in a background thread so
|
||||||
|
it doesn't block this request or any other user's requests while it
|
||||||
|
runs. Progress can be polled via GET /settings/update/status until the
|
||||||
|
container restarts with the new version.
|
||||||
|
|
||||||
The rebuild is fire-and-forget — the app will restart in ~60 seconds.
|
|
||||||
Only admin users may trigger an update.
|
Only admin users may trigger an update.
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ok, message, and backup path.
|
|
||||||
"""
|
"""
|
||||||
if getattr(current_user, "role", "admin") != "admin":
|
if getattr(current_user, "role", "admin") != "admin":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -383,11 +386,37 @@ async def trigger_update(
|
|||||||
detail="git_repo_url is not configured in settings.",
|
detail="git_repo_url is not configured in settings.",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = update_service.trigger_update(config, DATABASE_PATH)
|
current_status = update_service.get_update_status()
|
||||||
if not result.get("ok"):
|
if current_status.get("state") == "running":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail=result.get("message", "Update failed."),
|
detail="An update is already in progress.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Snapshot the only fields trigger_update() needs — avoids passing a
|
||||||
|
# SQLAlchemy instance into a background thread after this request's
|
||||||
|
# session may already be closed.
|
||||||
|
class _ConfigSnapshot:
|
||||||
|
git_repo_url = config.git_repo_url
|
||||||
|
git_branch = config.git_branch
|
||||||
|
git_token = config.git_token
|
||||||
|
|
||||||
|
asyncio.create_task(asyncio.to_thread(update_service.trigger_update, _ConfigSnapshot(), DATABASE_PATH))
|
||||||
logger.info("Update triggered by %s.", current_user.username)
|
logger.info("Update triggered by %s.", current_user.username)
|
||||||
return result
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "Update gestartet. Dies kann mehrere Minuten dauern — Fortschritt via Status sichtbar.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/update/status")
|
||||||
|
async def update_status(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return progress of the currently running (or last) update.
|
||||||
|
|
||||||
|
Note: once the container restarts mid-update, this endpoint stops
|
||||||
|
responding for a few seconds — that itself is a signal the swap is
|
||||||
|
happening. The frontend falls back to polling for the app coming back up.
|
||||||
|
"""
|
||||||
|
return update_service.get_update_status()
|
||||||
|
|||||||
@@ -27,13 +27,24 @@ async def _run_cmd(cmd: list[str], timeout: int = 120) -> subprocess.CompletedPr
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_client: Optional[docker.DockerClient] = None
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> docker.DockerClient:
|
def _get_client() -> docker.DockerClient:
|
||||||
"""Return a Docker client connected via the Unix socket.
|
"""Return a shared Docker client connected via the Unix socket.
|
||||||
|
|
||||||
|
The client is created once and reused — creating a new client per call
|
||||||
|
(as `docker.from_env()` does) re-negotiates the API version and opens a
|
||||||
|
fresh connection every time, which is wasteful when called once per
|
||||||
|
customer in a loop.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
docker.DockerClient instance.
|
docker.DockerClient instance.
|
||||||
"""
|
"""
|
||||||
return docker.from_env()
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = docker.from_env()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
async def compose_up(
|
async def compose_up(
|
||||||
@@ -212,6 +223,16 @@ def get_container_status(container_prefix: str) -> list[dict[str, Any]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def get_container_status_async(container_prefix: str) -> list[dict[str, Any]]:
|
||||||
|
"""Thread-offloaded wrapper around get_container_status().
|
||||||
|
|
||||||
|
Use this when checking status for multiple customers so the Docker SDK
|
||||||
|
calls run in the thread pool instead of blocking the event loop.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, get_container_status, container_prefix)
|
||||||
|
|
||||||
|
|
||||||
def get_container_logs(container_name: str, tail: int = 200) -> str:
|
def get_container_logs(container_name: str, tail: int = 200) -> str:
|
||||||
"""Retrieve recent logs from a container.
|
"""Retrieve recent logs from a container.
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,44 @@ async def pull_all_images(config) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_customer_container_image_status_async(container_prefix: str, config) -> dict[str, Any]:
|
||||||
|
"""Async, thread-offloaded version of get_customer_container_image_status().
|
||||||
|
|
||||||
|
Runs the per-service `docker inspect` subprocess calls concurrently in the
|
||||||
|
thread pool instead of sequentially blocking the event loop — use this
|
||||||
|
whenever checking status for multiple customers (e.g. dashboard/search
|
||||||
|
badge refresh, monitoring overview).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
services: dict mapping service name to status info
|
||||||
|
needs_update: True if any service has a different image ID than locally stored
|
||||||
|
"""
|
||||||
|
service_images = {
|
||||||
|
"management": config.netbird_management_image,
|
||||||
|
"signal": config.netbird_signal_image,
|
||||||
|
"relay": config.netbird_relay_image,
|
||||||
|
"dashboard": config.netbird_dashboard_image,
|
||||||
|
}
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
async def _check(svc: str, image: str) -> tuple[str, dict[str, Any]]:
|
||||||
|
container_name = f"{container_prefix}-{svc}"
|
||||||
|
container_id, local_id = await asyncio.gather(
|
||||||
|
loop.run_in_executor(None, get_container_image_id, container_name),
|
||||||
|
loop.run_in_executor(None, get_local_image_id, image),
|
||||||
|
)
|
||||||
|
if container_id and local_id:
|
||||||
|
up_to_date = container_id == local_id
|
||||||
|
else:
|
||||||
|
up_to_date = None # container not running or image not pulled
|
||||||
|
return svc, {"container": container_name, "image": image, "up_to_date": up_to_date}
|
||||||
|
|
||||||
|
pairs = await asyncio.gather(*[_check(svc, image) for svc, image in service_images.items()])
|
||||||
|
services = dict(pairs)
|
||||||
|
needs_update = any(s["up_to_date"] is False for s in services.values())
|
||||||
|
return {"services": services, "needs_update": needs_update}
|
||||||
|
|
||||||
|
|
||||||
def get_customer_container_image_status(container_prefix: str, config) -> dict[str, Any]:
|
def get_customer_container_image_status(container_prefix: str, config) -> dict[str, Any]:
|
||||||
"""Check which service containers are running outdated local images.
|
"""Check which service containers are running outdated local images.
|
||||||
|
|
||||||
|
|||||||
+94
-16
@@ -12,9 +12,12 @@ Let's Encrypt SSL certificates.
|
|||||||
Also manages NPM streams for STUN/TURN relay UDP ports.
|
Also manages NPM streams for STUN/TURN relay UDP ports.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -24,6 +27,14 @@ logger = logging.getLogger(__name__)
|
|||||||
# Timeout for NPM API calls (seconds)
|
# Timeout for NPM API calls (seconds)
|
||||||
NPM_TIMEOUT = 30
|
NPM_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Cached JWTs, keyed by (api_url, email). NPM issues a token that stays valid
|
||||||
|
# for a while (per its 'exp' claim), so re-logging in on every single API
|
||||||
|
# call — as this module used to do — adds a full extra round-trip per action
|
||||||
|
# for no reason.
|
||||||
|
_token_cache: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
_TOKEN_SAFETY_MARGIN = 60 # refresh this many seconds before actual expiry
|
||||||
|
_DEFAULT_TOKEN_TTL = 3600 # fallback if the 'exp' claim can't be parsed
|
||||||
|
|
||||||
|
|
||||||
def _get_forward_host() -> str:
|
def _get_forward_host() -> str:
|
||||||
"""Get the host machine's real IP address for NPM forwarding.
|
"""Get the host machine's real IP address for NPM forwarding.
|
||||||
@@ -90,6 +101,61 @@ async def _npm_login(client: httpx.AsyncClient, api_url: str, email: str, passwo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt_exp(token: str) -> float | None:
|
||||||
|
"""Best-effort decode of a JWT's 'exp' claim, without verifying the signature.
|
||||||
|
|
||||||
|
We only use this to size our own cache TTL — NPM itself still enforces
|
||||||
|
the real expiry server-side, so an inaccurate read here is harmless.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload_b64 = token.split(".")[1]
|
||||||
|
padding = "=" * (-len(payload_b64) % 4)
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
||||||
|
return payload.get("exp")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_token(
|
||||||
|
client: httpx.AsyncClient, api_url: str, email: str, password: str, force_refresh: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Return a cached NPM JWT if still valid, otherwise log in and cache it."""
|
||||||
|
cache_key = (api_url, email)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = _token_cache.get(cache_key)
|
||||||
|
if cached and time.time() < cached["expires_at"]:
|
||||||
|
return cached["token"]
|
||||||
|
|
||||||
|
token = await _npm_login(client, api_url, email, password)
|
||||||
|
exp = _decode_jwt_exp(token)
|
||||||
|
expires_at = (exp - _TOKEN_SAFETY_MARGIN) if exp else (time.time() + _DEFAULT_TOKEN_TTL)
|
||||||
|
_token_cache[cache_key] = {"token": token, "expires_at": expires_at}
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_with_reauth(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
method: str,
|
||||||
|
api_url: str,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
path: str,
|
||||||
|
headers: dict,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> tuple[httpx.Response, dict]:
|
||||||
|
"""Perform a request; if the cached token was rejected, refresh and retry once.
|
||||||
|
|
||||||
|
Returns the response and the (possibly updated) headers dict, so callers
|
||||||
|
can reuse the fresh token for any further requests in the same session.
|
||||||
|
"""
|
||||||
|
resp = await client.request(method, f"{api_url}{path}", headers=headers, **kwargs)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
token = await _get_token(client, api_url, email, password, force_refresh=True)
|
||||||
|
headers = {**headers, "Authorization": f"Bearer {token}"}
|
||||||
|
resp = await client.request(method, f"{api_url}{path}", headers=headers, **kwargs)
|
||||||
|
return resp, headers
|
||||||
|
|
||||||
|
|
||||||
async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]:
|
async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]:
|
||||||
"""Test connectivity to NPM by logging in and listing proxy hosts.
|
"""Test connectivity to NPM by logging in and listing proxy hosts.
|
||||||
|
|
||||||
@@ -103,9 +169,11 @@ async def test_npm_connection(api_url: str, email: str, password: str) -> dict[s
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, email, password)
|
token = await _get_token(client, api_url, email, password)
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await client.get(f"{api_url}/nginx/proxy-hosts", headers=headers)
|
resp, headers = await _request_with_reauth(
|
||||||
|
client, "GET", api_url, email, password, "/nginx/proxy-hosts", headers
|
||||||
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
count = len(resp.json())
|
count = len(resp.json())
|
||||||
return {"ok": True, "message": f"Connected. Login OK. {count} proxy hosts found."}
|
return {"ok": True, "message": f"Connected. Login OK. {count} proxy hosts found."}
|
||||||
@@ -136,9 +204,11 @@ async def list_certificates(api_url: str, email: str, password: str) -> dict[str
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, email, password)
|
token = await _get_token(client, api_url, email, password)
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await client.get(f"{api_url}/nginx/certificates", headers=headers)
|
resp, headers = await _request_with_reauth(
|
||||||
|
client, "GET", api_url, email, password, "/nginx/certificates", headers
|
||||||
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
result = []
|
result = []
|
||||||
for cert in resp.json():
|
for cert in resp.json():
|
||||||
@@ -263,10 +333,14 @@ async def create_proxy_host(
|
|||||||
"location ^~ /management.ManagementService/ {\n"
|
"location ^~ /management.ManagementService/ {\n"
|
||||||
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
|
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
|
||||||
" grpc_set_header Host $host;\n"
|
" grpc_set_header Host $host;\n"
|
||||||
|
" grpc_read_timeout 3600s;\n"
|
||||||
|
" grpc_send_timeout 3600s;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"location ^~ /signalexchange.SignalExchange/ {\n"
|
"location ^~ /signalexchange.SignalExchange/ {\n"
|
||||||
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
|
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
|
||||||
" grpc_set_header Host $host;\n"
|
" grpc_set_header Host $host;\n"
|
||||||
|
" grpc_read_timeout 3600s;\n"
|
||||||
|
" grpc_send_timeout 3600s;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
),
|
),
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -278,14 +352,15 @@ async def create_proxy_host(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=180) as client: # Long timeout for LE cert
|
async with httpx.AsyncClient(timeout=180) as client: # Long timeout for LE cert
|
||||||
token = await _npm_login(client, api_url, npm_email, npm_password)
|
token = await _get_token(client, api_url, npm_email, npm_password)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = await client.post(
|
resp, headers = await _request_with_reauth(
|
||||||
f"{api_url}/nginx/proxy-hosts", json=payload, headers=headers
|
client, "POST", api_url, npm_email, npm_password,
|
||||||
|
"/nginx/proxy-hosts", headers, json=payload,
|
||||||
)
|
)
|
||||||
if resp.status_code in (200, 201):
|
if resp.status_code in (200, 201):
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -538,14 +613,15 @@ async def create_stream(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, npm_email, npm_password)
|
token = await _get_token(client, api_url, npm_email, npm_password)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = await client.post(
|
resp, headers = await _request_with_reauth(
|
||||||
f"{api_url}/nginx/streams", json=payload, headers=headers
|
client, "POST", api_url, npm_email, npm_password,
|
||||||
|
"/nginx/streams", headers, json=payload,
|
||||||
)
|
)
|
||||||
if resp.status_code in (200, 201):
|
if resp.status_code in (200, 201):
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -583,10 +659,11 @@ async def delete_stream(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, npm_email, npm_password)
|
token = await _get_token(client, api_url, npm_email, npm_password)
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await client.delete(
|
resp, headers = await _request_with_reauth(
|
||||||
f"{api_url}/nginx/streams/{stream_id}", headers=headers
|
client, "DELETE", api_url, npm_email, npm_password,
|
||||||
|
f"/nginx/streams/{stream_id}", headers,
|
||||||
)
|
)
|
||||||
if resp.status_code in (200, 204):
|
if resp.status_code in (200, 204):
|
||||||
logger.info("Deleted NPM stream %d", stream_id)
|
logger.info("Deleted NPM stream %d", stream_id)
|
||||||
@@ -619,10 +696,11 @@ async def delete_proxy_host(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, npm_email, npm_password)
|
token = await _get_token(client, api_url, npm_email, npm_password)
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await client.delete(
|
resp, headers = await _request_with_reauth(
|
||||||
f"{api_url}/nginx/proxy-hosts/{proxy_id}", headers=headers
|
client, "DELETE", api_url, npm_email, npm_password,
|
||||||
|
f"/nginx/proxy-hosts/{proxy_id}", headers,
|
||||||
)
|
)
|
||||||
if resp.status_code in (200, 204):
|
if resp.status_code in (200, 204):
|
||||||
logger.info("Deleted NPM proxy host %d", proxy_id)
|
logger.info("Deleted NPM proxy host %d", proxy_id)
|
||||||
|
|||||||
@@ -20,6 +20,32 @@ SERVICE_NAME = "netbird-msp-appliance"
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# In-memory progress tracker for the currently running (or last) update.
|
||||||
|
# The container gets replaced mid-update, so this deliberately does NOT need
|
||||||
|
# to survive a restart — the frontend detects completion by polling until the
|
||||||
|
# app comes back up and reports a new version, not by reading a final status
|
||||||
|
# here. It exists so the UI can show *something* other than a frozen spinner
|
||||||
|
# while the backup/pull/build steps are in progress.
|
||||||
|
_update_status: dict[str, Any] = {
|
||||||
|
"state": "idle", # idle | running | failed
|
||||||
|
"step": "",
|
||||||
|
"message": "",
|
||||||
|
"started_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_update_status() -> dict[str, Any]:
|
||||||
|
"""Return a snapshot of the current update progress."""
|
||||||
|
return dict(_update_status)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_status(state: str, step: str, message: str = "") -> None:
|
||||||
|
_update_status["state"] = state
|
||||||
|
_update_status["step"] = step
|
||||||
|
_update_status["message"] = message
|
||||||
|
if step == "backup":
|
||||||
|
_update_status["started_at"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _get_compose_project_name() -> str:
|
def _get_compose_project_name() -> str:
|
||||||
"""Detect the compose project name from the running container's labels.
|
"""Detect the compose project name from the running container's labels.
|
||||||
@@ -233,10 +259,12 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
Dict with ok (bool), message, backup path, and pulled_branch.
|
Dict with ok (bool), message, backup path, and pulled_branch.
|
||||||
"""
|
"""
|
||||||
# 1. Backup database before any changes
|
# 1. Backup database before any changes
|
||||||
|
_set_status("running", "backup", "Datenbank wird gesichert …")
|
||||||
try:
|
try:
|
||||||
backup_path = backup_database(db_path)
|
backup_path = backup_database(db_path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Database backup failed: %s", exc)
|
logger.error("Database backup failed: %s", exc)
|
||||||
|
_set_status("failed", "backup", f"Database backup failed: {exc}")
|
||||||
return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None}
|
return {"ok": False, "message": f"Database backup failed: {exc}", "backup": None}
|
||||||
|
|
||||||
# 2. Build git pull command (embed token in URL if provided)
|
# 2. Build git pull command (embed token in URL if provided)
|
||||||
@@ -252,6 +280,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch]
|
pull_cmd = ["git", "-C", SOURCE_DIR, "pull", "origin", branch]
|
||||||
|
|
||||||
# 3. Git pull (synchronous — must complete before rebuild)
|
# 3. Git pull (synchronous — must complete before rebuild)
|
||||||
|
_set_status("running", "pull", f"Code wird von Branch '{branch}' geholt …")
|
||||||
# Ensure .git directory is owned by the process user (root inside container).
|
# Ensure .git directory is owned by the process user (root inside container).
|
||||||
# The .git dir may be owned by the host user after manual operations.
|
# The .git dir may be owned by the host user after manual operations.
|
||||||
try:
|
try:
|
||||||
@@ -270,13 +299,16 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
_set_status("failed", "pull", "git pull timed out after 120s.")
|
||||||
return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path}
|
return {"ok": False, "message": "git pull timed out after 120s.", "backup": backup_path}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
_set_status("failed", "pull", f"git pull error: {exc}")
|
||||||
return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path}
|
return {"ok": False, "message": f"git pull error: {exc}", "backup": backup_path}
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
stderr = result.stderr.strip()[:500]
|
stderr = result.stderr.strip()[:500]
|
||||||
logger.error("git pull failed (exit %d): %s", result.returncode, stderr)
|
logger.error("git pull failed (exit %d): %s", result.returncode, stderr)
|
||||||
|
_set_status("failed", "pull", f"git pull failed: {stderr}")
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": f"git pull failed: {stderr}",
|
"message": f"git pull failed: {stderr}",
|
||||||
@@ -348,6 +380,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
SERVICE_NAME,
|
SERVICE_NAME,
|
||||||
]
|
]
|
||||||
logger.info("Phase A: building new image …")
|
logger.info("Phase A: building new image …")
|
||||||
|
_set_status("running", "build", "Docker-Image wird gebaut (kann mehrere Minuten dauern) …")
|
||||||
try:
|
try:
|
||||||
build_result = subprocess.run(
|
build_result = subprocess.run(
|
||||||
build_cmd,
|
build_cmd,
|
||||||
@@ -360,15 +393,18 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
f.write(build_result.stderr)
|
f.write(build_result.stderr)
|
||||||
if build_result.returncode != 0:
|
if build_result.returncode != 0:
|
||||||
logger.error("Image build failed: %s", build_result.stderr[:500])
|
logger.error("Image build failed: %s", build_result.stderr[:500])
|
||||||
|
_set_status("failed", "build", f"Image build failed: {build_result.stderr[:300]}")
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": f"Image build failed: {build_result.stderr[:300]}",
|
"message": f"Image build failed: {build_result.stderr[:300]}",
|
||||||
"backup": backup_path,
|
"backup": backup_path,
|
||||||
}
|
}
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
_set_status("failed", "build", "Image build timed out after 600s.")
|
||||||
return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path}
|
return {"ok": False, "message": "Image build timed out after 600s.", "backup": backup_path}
|
||||||
|
|
||||||
logger.info("Phase A complete — image built successfully.")
|
logger.info("Phase A complete — image built successfully.")
|
||||||
|
_set_status("running", "restart", "Container wird neu gestartet …")
|
||||||
|
|
||||||
# Phase B — swap the container using a helper container.
|
# Phase B — swap the container using a helper container.
|
||||||
# When compose recreates our container, ALL processes inside die (PID namespace
|
# When compose recreates our container, ALL processes inside die (PID namespace
|
||||||
@@ -388,6 +424,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
raise ValueError("Could not find /app-source mount")
|
raise ValueError("Could not find /app-source mount")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to discover host source path: %s", exc)
|
logger.error("Failed to discover host source path: %s", exc)
|
||||||
|
_set_status("failed", "restart", f"Could not find host source path: {exc}")
|
||||||
return {"ok": False, "message": f"Could not find host source path: {exc}", "backup": backup_path}
|
return {"ok": False, "message": f"Could not find host source path: {exc}", "backup": backup_path}
|
||||||
|
|
||||||
logger.info("Host source directory: %s", host_source_dir)
|
logger.info("Host source directory: %s", host_source_dir)
|
||||||
@@ -426,6 +463,10 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Failed to start updater container: %s", result.stderr.strip())
|
logger.error("Failed to start updater container: %s", result.stderr.strip())
|
||||||
|
_set_status(
|
||||||
|
"failed", "restart",
|
||||||
|
f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}",
|
"message": f"Update-Container konnte nicht gestartet werden: {result.stderr.strip()[:200]}",
|
||||||
@@ -434,6 +475,7 @@ def trigger_update(config: Any, db_path: str) -> dict:
|
|||||||
logger.info("Phase B: updater container started — this container will restart in ~5s.")
|
logger.info("Phase B: updater container started — this container will restart in ~5s.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to launch updater: %s", exc)
|
logger.error("Failed to launch updater: %s", exc)
|
||||||
|
_set_status("failed", "restart", f"Updater launch failed: {exc}")
|
||||||
return {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path}
|
return {"ok": False, "message": f"Updater launch failed: {exc}", "backup": backup_path}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+66
-3
@@ -789,7 +789,7 @@ async function viewCustomer(id) {
|
|||||||
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
|
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
|
||||||
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
|
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
|
||||||
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
|
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
|
||||||
<button class="btn btn-outline-primary btn-sm me-1" onclick="customerAction(${id},'deploy',${JSON.stringify(data.name)})"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
|
<button class="btn btn-outline-primary btn-sm me-1" data-customer-name="${esc(data.name)}" onclick="customerAction(${id},'deploy',this.dataset.customerName)"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
|
||||||
<button class="btn btn-outline-warning btn-sm" id="btn-update-images-detail" onclick="updateCustomerImagesFromDetail(${id})">
|
<button class="btn btn-outline-warning btn-sm" id="btn-update-images-detail" onclick="updateCustomerImagesFromDetail(${id})">
|
||||||
<span id="update-detail-spinner" class="spinner-border spinner-border-sm d-none me-1"></span>
|
<span id="update-detail-spinner" class="spinner-border spinner-border-sm d-none me-1"></span>
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>${t('customer.updateImages')}
|
<i class="bi bi-arrow-repeat me-1"></i>${t('customer.updateImages')}
|
||||||
@@ -1374,11 +1374,12 @@ async function loadVersionInfo() {
|
|||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
html += `<div class="mt-3">
|
html += `<div class="mt-3">
|
||||||
<button class="btn btn-warning" onclick="triggerUpdate()">
|
<button class="btn btn-warning" id="update-trigger-btn" onclick="triggerUpdate()">
|
||||||
<span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span>
|
<span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span>
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
|
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
|
||||||
</button>
|
</button>
|
||||||
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
|
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
|
||||||
|
<div class="small mt-2 d-none" id="update-progress-text"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
@@ -1390,14 +1391,76 @@ async function loadVersionInfo() {
|
|||||||
async function triggerUpdate() {
|
async function triggerUpdate() {
|
||||||
if (!confirm(t('settings.confirmUpdate'))) return;
|
if (!confirm(t('settings.confirmUpdate'))) return;
|
||||||
const spinner = document.getElementById('update-spinner');
|
const spinner = document.getElementById('update-spinner');
|
||||||
|
const btn = document.getElementById('update-trigger-btn');
|
||||||
|
const progressText = document.getElementById('update-progress-text');
|
||||||
|
const setProgress = (msg) => {
|
||||||
|
if (!progressText) return;
|
||||||
|
progressText.classList.remove('d-none');
|
||||||
|
progressText.textContent = msg;
|
||||||
|
};
|
||||||
|
const stopUpdateUi = () => {
|
||||||
|
if (spinner) spinner.classList.add('d-none');
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
if (spinner) spinner.classList.remove('d-none');
|
if (spinner) spinner.classList.remove('d-none');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
setProgress(t('settings.updateStepStarting'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api('POST', '/settings/update');
|
const data = await api('POST', '/settings/update');
|
||||||
showSettingsAlert('success', data.message || t('messages.updateStarted'));
|
showSettingsAlert('success', data.message || t('messages.updateStarted'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
||||||
if (spinner) spinner.classList.add('d-none');
|
stopUpdateUi();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1: poll build/pull progress until the container restarts
|
||||||
|
// (connection drops, which is expected and is our cue to move to phase 2).
|
||||||
|
const stepLabelKey = {
|
||||||
|
backup: 'settings.updateStepBackup',
|
||||||
|
pull: 'settings.updateStepPull',
|
||||||
|
build: 'settings.updateStepBuild',
|
||||||
|
restart: 'settings.updateStepRestart',
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
try {
|
||||||
|
const st = await api('GET', '/settings/update/status');
|
||||||
|
if (st.state === 'failed') {
|
||||||
|
showSettingsAlert('danger', st.message || t('errors.requestFailed'));
|
||||||
|
stopUpdateUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProgress(t(stepLabelKey[st.step] || 'settings.updateStepStarting') + (st.message ? ` — ${st.message}` : ''));
|
||||||
|
} catch (err) {
|
||||||
|
// Connection dropped — the container is very likely mid-restart. Move on.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: wait for the app to come back up, then reload version info.
|
||||||
|
setProgress(t('settings.updateStepReconnecting'));
|
||||||
|
for (let i = 0; i < 90; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
try {
|
||||||
|
await api('GET', '/settings/version');
|
||||||
|
setProgress(t('settings.updateStepDone'));
|
||||||
|
stopUpdateUi();
|
||||||
|
showSettingsAlert('success', t('settings.updateStepDone'));
|
||||||
|
await loadVersionInfo();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// still restarting — keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gave up waiting — surface this instead of spinning forever.
|
||||||
|
stopUpdateUi();
|
||||||
|
setProgress('');
|
||||||
|
if (progressText) progressText.classList.add('d-none');
|
||||||
|
showSettingsAlert('warning', t('settings.updateStepTimeout'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -230,6 +230,14 @@
|
|||||||
"triggerUpdate": "Update starten",
|
"triggerUpdate": "Update starten",
|
||||||
"updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.",
|
"updateWarning": "Die App ist während des Rebuilds ca. 60 Sekunden nicht verfügbar.",
|
||||||
"confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).",
|
"confirmUpdate": "Update jetzt starten? Die Datenbank wird zuerst gesichert. Die App startet neu (~60 Sekunden Ausfallzeit).",
|
||||||
|
"updateStepStarting": "Update wird gestartet …",
|
||||||
|
"updateStepBackup": "Datenbank wird gesichert …",
|
||||||
|
"updateStepPull": "Code wird geholt …",
|
||||||
|
"updateStepBuild": "Docker-Image wird gebaut (kann mehrere Minuten dauern) …",
|
||||||
|
"updateStepRestart": "Container wird neu gestartet …",
|
||||||
|
"updateStepReconnecting": "Container startet neu — warte auf Verbindung …",
|
||||||
|
"updateStepDone": "Update abgeschlossen.",
|
||||||
|
"updateStepTimeout": "Update läuft länger als erwartet. Bitte Server-Logs prüfen oder die Seite in ein paar Minuten neu laden.",
|
||||||
"gitTitle": "Git-Repository Einstellungen",
|
"gitTitle": "Git-Repository Einstellungen",
|
||||||
"gitRepoUrl": "Repository URL",
|
"gitRepoUrl": "Repository URL",
|
||||||
"gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.",
|
"gitRepoUrlHint": "Wird für Versionsprüfungen und One-Click-Updates via Gitea API verwendet.",
|
||||||
|
|||||||
@@ -262,6 +262,14 @@
|
|||||||
"triggerUpdate": "Start Update",
|
"triggerUpdate": "Start Update",
|
||||||
"updateWarning": "The app will be unavailable for ~60 seconds during rebuild.",
|
"updateWarning": "The app will be unavailable for ~60 seconds during rebuild.",
|
||||||
"confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).",
|
"confirmUpdate": "Start the update now? The database will be backed up first. The app will restart (~60 seconds downtime).",
|
||||||
|
"updateStepStarting": "Starting update …",
|
||||||
|
"updateStepBackup": "Backing up database …",
|
||||||
|
"updateStepPull": "Fetching code …",
|
||||||
|
"updateStepBuild": "Building Docker image (can take several minutes) …",
|
||||||
|
"updateStepRestart": "Restarting container …",
|
||||||
|
"updateStepReconnecting": "Container is restarting — waiting for connection …",
|
||||||
|
"updateStepDone": "Update complete.",
|
||||||
|
"updateStepTimeout": "Update is taking longer than expected. Check the server logs or reload this page in a few minutes.",
|
||||||
"gitTitle": "Git Repository Settings",
|
"gitTitle": "Git Repository Settings",
|
||||||
"gitRepoUrl": "Repository URL",
|
"gitRepoUrl": "Repository URL",
|
||||||
"gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",
|
"gitRepoUrlHint": "Used for version checks and one-click updates via Gitea API.",
|
||||||
|
|||||||
Reference in New Issue
Block a user