Compare commits

..
11 Commits
Author SHA1 Message Date
twothatITandClaude Sonnet 5 c5189d88fe perf(npm): cache NPM JWT instead of re-authenticating on every API call
Every NPM helper (proxy host create/update/delete, streams, certs) did a
fresh POST /api/tokens login before its actual request, adding an avoidable
round-trip to every proxy/stream operation.

- Cache the JWT per (api_url, email), sized from its 'exp' claim
- Transparently re-authenticate and retry once on a 401 (e.g. after an NPM
  restart invalidates a cached token), so a stale cache entry can't cause a
  hard failure

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 14:51:23 +02:00
twothatITandClaude Sonnet 5 ac843da4ca perf(monitoring): stop blocking event loop with synchronous Docker calls
Customer search and detail loads were intermittently slow because every
customer-table render (including each search keystroke) triggered
/monitoring/customers/local-update-status, which looped synchronously over
all customers doing blocking `docker inspect` subprocess calls on the event
loop — stalling all other in-flight requests, including search itself.

- Offload per-service image/container inspection to the thread pool and run
  checks concurrently instead of sequentially (image_service, docker_service)
- Reuse a single Docker SDK client instead of reconnecting per customer
- Cache local-update-status results for 20s since the underlying data only
  changes after an image pull, not on every keystroke
- Parallelize /monitoring/customers container status lookups

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 14:44:56 +02:00
twothatITandClaude Sonnet 4.6 f6b7eb2dae fix(npm): add gRPC read/send timeouts to proxy host location blocks
Adds grpc_read_timeout 3600s and grpc_send_timeout 3600s to both
ManagementService and SignalExchange location blocks to prevent
long-lived gRPC connections from being dropped by Nginx.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-06 12:01:14 +02:00
twothatITandClaude Sonnet 4.6 8ede0f0a3c fix(deploy): fix redeploy button broken by JSON.stringify double quotes
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 22:13:23 +01:00
twothatITandClaude Sonnet 4.6 8040973227 fix(deploy): fix redeploy button broken by JSON.stringify double quotes
JSON.stringify('Name') produces "Name" with double quotes which breaks
the onclick attribute. Use data-customer-name attribute instead and
read it via this.dataset.customerName to avoid quoting issues.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 22:13:06 +01:00
twothatITandClaude Sonnet 4.6 3cdc82f919 fix(deploy): show customer name in redeploy modal instead of ID
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 22:08:35 +01:00
twothatITandClaude Sonnet 4.6 40595fc381 fix(deploy): show customer name in redeploy modal instead of ID
The modal was showing '#2' instead of the customer name when opened
from the customer detail view, because the dashboard table row was
not visible. Now the name is passed directly from the button's onclick
context where data.name is already available.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 22:08:17 +01:00
twothatITandClaude Sonnet 4.6 9ace554427 fix(cache): bust browser cache for JS and i18n files after updates
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 21:57:36 +01:00
twothatITandClaude Sonnet 4.6 f48c851ef0 fix(cache): bust browser cache for JS and i18n files after updates
After a container update, browsers serve stale app.js and lang/*.json
from cache, causing old UI code and missing translations to appear.

- serve_index() now reads the git commit hash and injects ?v=COMMIT into
  all static asset URLs (app.js, i18n.js, styles.css) in index.html
- window.STATIC_VERSION is injected into the page so i18n.js can append
  the same version to lang/*.json fetch calls
- index.html itself is served with Cache-Control: no-cache so the browser
  always revalidates it and picks up new asset URLs on next load

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 21:57:18 +01:00
twothatITandClaude Sonnet 4.6 7d694c62bd feat(deploy): redeploy dialog with keep-data or fresh-deploy option
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 21:34:26 +01:00
twothatITandClaude Sonnet 4.6 d1bb6a633e feat(deploy): redeploy dialog with keep-data or fresh-deploy option
Add a confirmation modal when clicking Redeploy that lets the user choose:
- Keep Data: containers are recreated without wiping the instance directory.
  NetBird database, peer configs, and encryption keys are preserved.
- Fresh Deploy: full undeploy (removes all data) then redeploy from scratch.

Backend changes:
- POST /customers/{id}/deploy accepts keep_data query param (default false)
- When keep_data=true, undeploy_customer is skipped entirely
- deploy_customer now reuses existing npm_proxy_id/stream_id when the
  deployment record is still present (avoids duplicate NPM proxy entries)
- DNS record creation is skipped on keep_data redeploy (already exists)

Frontend changes:
- customerAction('deploy') opens the redeploy modal instead of calling API
- showRedeployModal(id) shows the two-option confirmation card dialog
- confirmRedeploy(keepData) calls the API with the correct parameter
- i18n keys added in en.json and de.json

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-10 21:34:12 +01:00
12 changed files with 338 additions and 49 deletions
+27 -7
View File
@@ -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.1",
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",
@@ -90,16 +90,36 @@ STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
if os.path.isdir(STATIC_DIR): if os.path.isdir(STATIC_DIR):
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Serve index.html at root # Serve index.html at root — inject cache-busting version into static asset URLs
from fastapi.responses import FileResponse # so the browser always loads fresh JS/CSS after a container update.
from fastapi.responses import FileResponse, HTMLResponse
from app.services import update_service
_STATIC_ASSETS = (
'"/static/js/app.js"',
'"/static/js/i18n.js"',
'"/static/css/styles.css"',
)
def _cache_bust_index(html: str, version: str) -> str:
# Inject version as a global JS variable so i18n.js can bust lang file caches too
html = html.replace("</head>", f'<script>window.STATIC_VERSION="{version}";</script>\n</head>', 1)
for asset in _STATIC_ASSETS:
busted = asset.rstrip('"') + f'?v={version}"'
html = html.replace(asset, busted)
return html
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
async def serve_index(): async def serve_index():
"""Serve the main dashboard.""" """Serve the main dashboard with cache-busted static asset URLs."""
index_path = os.path.join(STATIC_DIR, "index.html") index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.isfile(index_path): if not os.path.isfile(index_path):
return FileResponse(index_path) return JSONResponse({"message": "NetBird MSP Appliance API is running."})
return JSONResponse({"message": "NetBird MSP Appliance API is running."}) version = update_service.get_current_version().get("commit", "unknown")
html = open(index_path, encoding="utf-8").read()
html = _cache_bust_index(html, version)
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+14 -5
View File
@@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db from app.database import SessionLocal, get_db
@@ -19,6 +19,14 @@ router = APIRouter()
async def manual_deploy( async def manual_deploy(
customer_id: int, customer_id: int,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
keep_data: bool = Query(
False,
description=(
"If True, preserve existing NetBird data (database, keys, peers). "
"Containers are recreated without wiping the instance directory. "
"If False (default), the instance is fully removed and redeployed from scratch."
),
),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -29,6 +37,7 @@ async def manual_deploy(
Args: Args:
customer_id: Customer ID. customer_id: Customer ID.
keep_data: Whether to preserve existing NetBird data.
Returns: Returns:
Acknowledgement dict. Acknowledgement dict.
@@ -40,12 +49,12 @@ async def manual_deploy(
customer.status = "deploying" customer.status = "deploying"
db.commit() db.commit()
async def _deploy_bg(cid: int) -> None: async def _deploy_bg(cid: int, keep: bool) -> None:
bg_db = SessionLocal() bg_db = SessionLocal()
try: try:
# Remove existing deployment if present
existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first() existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first()
if existing: if existing and not keep:
# Full redeploy: remove everything first
await netbird_service.undeploy_customer(bg_db, cid) await netbird_service.undeploy_customer(bg_db, cid)
await netbird_service.deploy_customer(bg_db, cid) await netbird_service.deploy_customer(bg_db, cid)
except Exception: except Exception:
@@ -53,7 +62,7 @@ async def manual_deploy(
finally: finally:
bg_db.close() bg_db.close()
background_tasks.add_task(_deploy_bg, customer_id) background_tasks.add_task(_deploy_bg, customer_id, keep_data)
return {"message": "Deployment started in background.", "status": "deploying"} return {"message": "Deployment started in background.", "status": "deploying"}
+32 -9
View File
@@ -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
+23 -2
View File
@@ -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.
+38
View File
@@ -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.
+13 -6
View File
@@ -264,10 +264,12 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
_log_action(db, customer_id, "deploy", "info", _log_action(db, customer_id, "deploy", "info",
"Auto-setup failed — admin must complete setup manually.") "Auto-setup failed — admin must complete setup manually.")
# Step 9: Create NPM proxy host (production only) # Step 9: Create NPM proxy host (production only).
npm_proxy_id = None # If an existing deployment already has an NPM proxy, reuse it — this happens
npm_stream_id = None # when keep_data=True was passed and undeploy_customer was NOT called beforehand.
if not local_mode: npm_proxy_id = existing_deployment.npm_proxy_id if existing_deployment else None
npm_stream_id = existing_deployment.npm_stream_id if existing_deployment else None
if not local_mode and not npm_proxy_id:
forward_host = npm_service._get_forward_host() forward_host = npm_service._get_forward_host()
npm_result = await npm_service.create_proxy_host( npm_result = await npm_service.create_proxy_host(
api_url=config.npm_api_url, api_url=config.npm_api_url,
@@ -304,9 +306,14 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
"SSL certificate not created automatically. " "SSL certificate not created automatically. "
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.", "Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.",
) )
elif npm_proxy_id and not local_mode:
_log_action(db, customer_id, "deploy", "info",
f"Reusing existing NPM proxy (ID {npm_proxy_id}) — data preserved.")
# Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment) # Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment).
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip: # Skip if an existing deployment is being kept (DNS record already exists).
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip \
and not existing_deployment:
try: try:
dns_result = await dns_service.create_dns_record(customer.subdomain, config) dns_result = await dns_service.create_dns_record(customer.subdomain, config)
if dns_result["ok"]: if dns_result["ok"]:
+94 -16
View File
@@ -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)
+43
View File
@@ -1267,6 +1267,49 @@
</div> </div>
</div> </div>
<!-- Modal: Redeploy Confirmation -->
<div class="modal fade" id="redeploy-modal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" data-i18n="redeployModal.title">Redeploy Customer</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p><span data-i18n="redeployModal.intro">How should</span> <strong id="redeploy-customer-name"></strong> <span data-i18n="redeployModal.intro2">be redeployed?</span></p>
<input type="hidden" id="redeploy-customer-id">
<div class="row g-3 mt-1">
<div class="col-md-6">
<div class="card h-100 border-success" style="cursor:pointer" onclick="confirmRedeploy(true)" id="redeploy-card-keep">
<div class="card-body">
<h6 class="card-title text-success"><i class="bi bi-shield-check me-2"></i><span data-i18n="redeployModal.keepTitle">Keep Data</span></h6>
<p class="card-text small" data-i18n="redeployModal.keepDesc">Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.</p>
</div>
<div class="card-footer bg-success bg-opacity-10 text-success small" data-i18n="redeployModal.keepNote">
Peers stay connected after restart.
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100 border-danger" style="cursor:pointer" onclick="confirmRedeploy(false)" id="redeploy-card-fresh">
<div class="card-body">
<h6 class="card-title text-danger"><i class="bi bi-trash me-2"></i><span data-i18n="redeployModal.freshTitle">Fresh Deploy</span></h6>
<p class="card-text small" data-i18n="redeployModal.freshDesc">All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.</p>
</div>
<div class="card-footer bg-danger bg-opacity-10 text-danger small" data-i18n="redeployModal.freshNote">
All peer data is lost. Cannot be undone.
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Modal: Delete Confirmation --> <!-- Modal: Delete Confirmation -->
<div class="modal fade" id="delete-modal" tabindex="-1"> <div class="modal fade" id="delete-modal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
+30 -3
View File
@@ -667,9 +667,13 @@ async function confirmDeleteCustomer() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Customer Actions (start/stop/restart) // Customer Actions (start/stop/restart/deploy)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function customerAction(id, action) { async function customerAction(id, action, name) {
if (action === 'deploy') {
showRedeployModal(id, name);
return;
}
try { try {
await api('POST', `/customers/${id}/${action}`); await api('POST', `/customers/${id}/${action}`);
if (currentPage === 'dashboard') loadCustomers(); if (currentPage === 'dashboard') loadCustomers();
@@ -679,6 +683,29 @@ async function customerAction(id, action) {
} }
} }
function showRedeployModal(id, name) {
// Prefer passed name, fallback to dashboard table row, then ID
if (!name) {
const row = document.querySelector(`tr[data-customer-id="${id}"]`);
name = row ? row.querySelector('td')?.textContent?.trim() : `#${id}`;
}
document.getElementById('redeploy-customer-id').value = id;
document.getElementById('redeploy-customer-name').textContent = name;
new bootstrap.Modal(document.getElementById('redeploy-modal')).show();
}
async function confirmRedeploy(keepData) {
const id = document.getElementById('redeploy-customer-id').value;
bootstrap.Modal.getInstance(document.getElementById('redeploy-modal'))?.hide();
try {
await api('POST', `/customers/${id}/deploy?keep_data=${keepData}`);
if (currentPage === 'dashboard') loadCustomers();
if (currentCustomerId == id) viewCustomer(id);
} catch (err) {
alert(t('errors.actionFailed', { action: 'deploy', error: err.message }));
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Customer Detail // Customer Detail
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -762,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')"><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')}
+2 -1
View File
@@ -27,7 +27,8 @@ function detectLanguage() {
async function loadLanguage(lang) { async function loadLanguage(lang) {
if (translations[lang]) return; if (translations[lang]) return;
try { try {
const resp = await fetch(`/static/lang/${lang}.json`); const v = window.STATIC_VERSION ? `?v=${window.STATIC_VERSION}` : '';
const resp = await fetch(`/static/lang/${lang}.json${v}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
translations[lang] = await resp.json(); translations[lang] = await resp.json();
} catch (err) { } catch (err) {
+11
View File
@@ -354,6 +354,17 @@
"saveAndDeploy": "Speichern & Bereitstellen", "saveAndDeploy": "Speichern & Bereitstellen",
"saveChanges": "Änderungen speichern" "saveChanges": "Änderungen speichern"
}, },
"redeployModal": {
"title": "Kunde neu bereitstellen",
"intro": "Wie soll",
"intro2": "neu bereitgestellt werden?",
"keepTitle": "Daten behalten",
"keepDesc": "Container werden gestoppt und neu gestartet. Die NetBird-Datenbank, Peer-Konfigurationen und Verschlüsselungsschlüssel bleiben erhalten. Verwenden Sie dies nach einer Konfigurationsänderung oder einem Image-Update.",
"keepNote": "Peers bleiben nach dem Neustart verbunden.",
"freshTitle": "Neu aufsetzen",
"freshDesc": "Alle bestehenden Daten werden gelöscht — Container, Volumes, Konfigurationsdateien und die NetBird-Datenbank. Eine komplett neue Instanz wird erstellt. Alle Peers müssen sich neu registrieren.",
"freshNote": "Alle Peer-Daten gehen verloren. Kann nicht rückgängig gemacht werden."
},
"deleteModal": { "deleteModal": {
"title": "Löschen bestätigen", "title": "Löschen bestätigen",
"confirmText": "Möchten Sie den Kunden wirklich löschen:", "confirmText": "Möchten Sie den Kunden wirklich löschen:",
+11
View File
@@ -107,6 +107,17 @@
"saveAndDeploy": "Save & Deploy", "saveAndDeploy": "Save & Deploy",
"saveChanges": "Save Changes" "saveChanges": "Save Changes"
}, },
"redeployModal": {
"title": "Redeploy Customer",
"intro": "How should",
"intro2": "be redeployed?",
"keepTitle": "Keep Data",
"keepDesc": "Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.",
"keepNote": "Peers stay connected after restart.",
"freshTitle": "Fresh Deploy",
"freshDesc": "All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.",
"freshNote": "All peer data is lost. Cannot be undone."
},
"deleteModal": { "deleteModal": {
"title": "Confirm Deletion", "title": "Confirm Deletion",
"confirmText": "Are you sure you want to delete customer", "confirmText": "Are you sure you want to delete customer",