Compare commits

...
5 Commits
Author SHA1 Message Date
twothatIT 51fbd44809 chore(release): bump version to 1.3.0 2026-08-19 09:12:08 +02:00
twothatIT e53539231e fix(monitoring): repair silent-false-positive update badge + auto-update scheduling
Customer container status checks looked up containers by an exact expected
name. When a docker compose recreate got interrupted (e.g. a hung command
previously killed the whole update-all batch on timeout), Compose could leave
the old container renamed with a random hash prefix instead of removed. The
exact-name lookup then found nothing, returned None, and that silently
counted as "up to date" (green "Aktuell") instead of surfacing as unknown —
affecting 5 customers on the appliance whose containers were actually still
running under orphaned names.

- _run_cmd no longer raises on subprocess timeout, so one stuck customer
  can't abort the rest of a batch update
- repair_container_naming() self-heals orphaned hash-renamed containers by
  renaming them back before every status check and before recreate
- update-all loop now catches per-customer exceptions instead of aborting
- status responses expose "unknown" separately from "needs_update" so the UI
  shows a distinct grey badge instead of a false-positive green one
- new settings: automatic daily update check (on/off + time), with an
  independent toggle for whether it also auto-recreates customer containers
2026-08-19 09:10:29 +02:00
twothatITandClaude Sonnet 5 0e38b8083c docs(readme): update Updates section and API reference to match current app
- Document the one-click background update with live progress as the
  recommended path (manual git pull/compose is now the fallback)
- Add sortable customer columns feature
- Add missing API endpoints (settings/version, settings/update, update/status,
  sort_by/sort_order on GET /customers)
- Add missing Performance Tuning entry to table of contents
- De-pin the "Built With AI" model name so it doesn't go stale

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 15:32:54 +02:00
twothatITandClaude Sonnet 5 0e2b292408 feat(customers): sortable table columns + default ascending ID order
- Customer list now defaults to ascending ID order instead of newest-first,
  so the table starts at customer #1 instead of the highest ID
- Add sort_by/sort_order query params to GET /customers (whitelisted column
  map to prevent SQL injection via arbitrary column names)
- Make ID/Name/Subdomain/Status/Devices/Created column headers clickable,
  toggling asc/desc with a visual arrow indicator

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 15:16:31 +02:00
twothatITandClaude Sonnet 5 a5988af6a3 fix(update): stop blocking event loop during rebuild + fix infinite spinner
The update endpoint ran the entire git pull + docker build (up to 10 min)
synchronously inside the request handler, blocking the whole server for
everyone while it ran. Separately, the frontend spinner was only hidden on
error, never on success, so it spun forever even when the update worked.

- Run the update in a background thread; the request returns immediately
- Add GET /settings/update/status for progress polling (backup/pull/build/restart)
- Frontend polls status, then waits for the app to come back after the
  container restart, and shows a clear done/timeout message instead of an
  endless spinner

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-23 15:09:42 +02:00
16 changed files with 613 additions and 44 deletions
+18 -4
View File
@@ -23,6 +23,7 @@ A management solution for running isolated NetBird instances for your MSP busine
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Updates](#updates) - [Updates](#updates)
- [Security Best Practices](#security-best-practices) - [Security Best Practices](#security-best-practices)
- [Performance Tuning](#performance-tuning)
- [License](#license) - [License](#license)
--- ---
@@ -44,6 +45,7 @@ A management solution for running isolated NetBird instances for your MSP busine
- **Start / Stop / Restart** — Control customer instances from the dashboard - **Start / Stop / Restart** — Control customer instances from the dashboard
- **Customer Status Tracking** — Automatic status sync (active / inactive / error) - **Customer Status Tracking** — Automatic status sync (active / inactive / error)
- **Update Indicators** — Per-customer badges when container images are outdated - **Update Indicators** — Per-customer badges when container images are outdated
- **Sortable Columns** — Click any customer list column header (ID, Name, Subdomain, Status, Devices, Created) to sort ascending/descending
### NetBird Container Updates ### NetBird Container Updates
- **Docker Hub Digest Check** — Compare locally pulled image digests against Docker Hub without pulling - **Docker Hub Digest Check** — Compare locally pulled image digests against Docker Hub without pulling
@@ -74,7 +76,7 @@ A management solution for running isolated NetBird instances for your MSP busine
### Integrations ### Integrations
- **Windows DNS** — Automatically create and delete DNS A-records when deploying or removing customers - **Windows DNS** — Automatically create and delete DNS A-records when deploying or removing customers
- **MSP Updates** — In-UI appliance update check with configurable release branch - **MSP Updates** — In-UI appliance version check, configurable release branch, and one-click background update with live progress
--- ---
@@ -483,7 +485,7 @@ http://your-server:8000/api/docs
**Common Endpoints:** **Common Endpoints:**
``` ```
POST /api/customers # Create customer + deploy POST /api/customers # Create customer + deploy
GET /api/customers # List all customers GET /api/customers # List customers (supports search, status filter, sort_by/sort_order)
GET /api/customers/{id} # Get customer details GET /api/customers/{id} # Get customer details
PUT /api/customers/{id} # Update customer PUT /api/customers/{id} # Update customer
DELETE /api/customers/{id} # Delete customer DELETE /api/customers/{id} # Delete customer
@@ -498,6 +500,9 @@ POST /api/customers/{id}/update-images # Recreate containers with new images
GET /api/settings/branding # Get branding (public, no auth) GET /api/settings/branding # Get branding (public, no auth)
GET /api/settings/npm-certificates # List NPM SSL certificates GET /api/settings/npm-certificates # List NPM SSL certificates
PUT /api/settings # Update system settings PUT /api/settings # Update system settings
GET /api/settings/version # Current + latest available appliance version
POST /api/settings/update # Start appliance update in the background
GET /api/settings/update/status # Poll update progress (backup/pull/build/restart)
GET /api/users # List users GET /api/users # List users
POST /api/users # Create user POST /api/users # Create user
@@ -581,6 +586,15 @@ docker logs -f netbird-msp-appliance
### Updating the Appliance ### Updating the Appliance
The recommended way to update is the built-in one-click updater:
1. Go to **Settings > NetBird MSP Updates**
2. Configure the Git repository URL and branch (defaults to `main`) if not already set
3. Click **"Update starten"** ("Start Update")
This backs up the database, pulls the configured branch, rebuilds the container image, and swaps in the new container — all in the background. The page shows live progress (backup → pull → build → restart) and automatically detects when the app is back up, so there's no need to babysit a terminal. The app is unavailable for roughly 30-60 seconds during the container swap.
**Manual update (fallback, e.g. no Web UI access):**
```bash ```bash
cd /opt/netbird-msp cd /opt/netbird-msp
git pull git pull
@@ -588,7 +602,7 @@ docker compose down
docker compose up -d --build docker compose up -d --build
``` ```
The database migrations run automatically on startup. The database migrations run automatically on startup either way.
### Updating NetBird Images ### Updating NetBird Images
@@ -667,7 +681,7 @@ MIT License — see [LICENSE](LICENSE) file for details.
## Built With AI ## Built With AI
This software was developed with [Claude Code](https://claude.ai/claude-code) (Anthropic Claude Sonnet 4.6) — from architecture and backend logic to frontend UI and deployment scripts. This software was developed and is continuously maintained with [Claude Code](https://claude.ai/claude-code) (Anthropic) — from architecture and backend logic to frontend UI and deployment scripts.
## Acknowledgments ## Acknowledgments
+5
View File
@@ -122,6 +122,11 @@ def _run_migrations() -> None:
("system_config", "git_repo_url", "TEXT"), ("system_config", "git_repo_url", "TEXT"),
("system_config", "git_branch", "TEXT DEFAULT 'main'"), ("system_config", "git_branch", "TEXT DEFAULT 'main'"),
("system_config", "git_token_encrypted", "TEXT"), ("system_config", "git_token_encrypted", "TEXT"),
# Automatic NetBird image update check/apply
("system_config", "auto_update_check_enabled", "BOOLEAN DEFAULT 0"),
("system_config", "auto_update_check_time", "TEXT DEFAULT '03:00'"),
("system_config", "auto_update_apply_enabled", "BOOLEAN DEFAULT 0"),
("system_config", "auto_update_last_run_at", "TEXT"),
] ]
for table, column, col_type in migrations: for table, column, col_type in migrations:
if not _has_column(table, column): if not _has_column(table, column):
+9 -1
View File
@@ -13,6 +13,7 @@ from slowapi.errors import RateLimitExceeded
from app.database import init_db from app.database import init_db
from app.limiter import limiter from app.limiter import limiter
from app.routers import auth, customers, deployments, monitoring, settings, users from app.routers import auth, customers, deployments, monitoring, settings, users
from app.services import scheduler_service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Logging # Logging
@@ -33,7 +34,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.1.1", version="1.3.0",
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",
@@ -140,3 +141,10 @@ async def startup_event():
logger.info("Starting NetBird MSP Appliance...") logger.info("Starting NetBird MSP Appliance...")
init_db() init_db()
logger.info("Database initialized.") logger.info("Database initialized.")
scheduler_service.start()
@app.on_event("shutdown")
async def shutdown_event():
"""Stop background tasks on shutdown."""
scheduler_service.stop()
+12
View File
@@ -199,6 +199,12 @@ class SystemConfig(Base):
git_branch: Mapped[Optional[str]] = mapped_column(String(100), default="main") git_branch: Mapped[Optional[str]] = mapped_column(String(100), default="main")
git_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) git_token_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Automatic NetBird image update check/apply
auto_update_check_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
auto_update_check_time: Mapped[Optional[str]] = mapped_column(String(5), default="03:00")
auto_update_apply_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
auto_update_last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
@@ -253,6 +259,12 @@ class SystemConfig(Base):
"git_repo_url": self.git_repo_url or "", "git_repo_url": self.git_repo_url or "",
"git_branch": self.git_branch or "main", "git_branch": self.git_branch or "main",
"git_token_set": bool(self.git_token_encrypted), "git_token_set": bool(self.git_token_encrypted),
"auto_update_check_enabled": bool(self.auto_update_check_enabled),
"auto_update_check_time": self.auto_update_check_time or "03:00",
"auto_update_apply_enabled": bool(self.auto_update_apply_enabled),
"auto_update_last_run_at": (
self.auto_update_last_run_at.isoformat() if self.auto_update_last_run_at else None
),
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
} }
+19 -2
View File
@@ -78,22 +78,36 @@ async def create_customer(
return response return response
SORTABLE_CUSTOMER_COLUMNS = {
"id": Customer.id,
"name": Customer.name,
"subdomain": Customer.subdomain,
"status": Customer.status,
"max_devices": Customer.max_devices,
"created_at": Customer.created_at,
}
@router.get("") @router.get("")
async def list_customers( async def list_customers(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
per_page: int = Query(default=25, ge=1, le=100), per_page: int = Query(default=25, ge=1, le=100),
search: Optional[str] = Query(default=None), search: Optional[str] = Query(default=None),
status_filter: Optional[str] = Query(default=None, alias="status"), status_filter: Optional[str] = Query(default=None, alias="status"),
sort_by: str = Query(default="id"),
sort_order: str = Query(default="asc", pattern="^(asc|desc)$"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""List customers with pagination, search, and status filter. """List customers with pagination, search, status filter, and sorting.
Args: Args:
page: Page number (1-indexed). page: Page number (1-indexed).
per_page: Items per page. per_page: Items per page.
search: Search in name, subdomain, email. search: Search in name, subdomain, email.
status_filter: Filter by status. status_filter: Filter by status.
sort_by: Column to sort by — one of SORTABLE_CUSTOMER_COLUMNS.
sort_order: "asc" or "desc".
Returns: Returns:
Paginated customer list with metadata. Paginated customer list with metadata.
@@ -113,8 +127,11 @@ async def list_customers(
query = query.filter(Customer.status == status_filter) query = query.filter(Customer.status == status_filter)
total = query.count() total = query.count()
sort_column = SORTABLE_CUSTOMER_COLUMNS.get(sort_by, Customer.id)
sort_expr = sort_column.desc() if sort_order == "desc" else sort_column.asc()
customers = ( customers = (
query.order_by(Customer.created_at.desc()) query.order_by(sort_expr, Customer.id.asc())
.offset((page - 1) * per_page) .offset((page - 1) * per_page)
.limit(per_page) .limit(per_page)
.all() .all()
+13 -4
View File
@@ -159,6 +159,7 @@ async def check_image_updates(
"subdomain": customer.subdomain, "subdomain": customer.subdomain,
"container_prefix": dep.container_prefix, "container_prefix": dep.container_prefix,
"needs_update": cs["needs_update"], "needs_update": cs["needs_update"],
"unknown": cs.get("unknown", False),
"services": cs["services"], "services": cs["services"],
}) })
@@ -231,7 +232,7 @@ async def customers_local_update_status(
async def _check(dep: Deployment) -> dict[str, Any]: async def _check(dep: Deployment) -> dict[str, Any]:
cs = await image_service.get_customer_container_image_status_async(dep.container_prefix, config) cs = await image_service.get_customer_container_image_status_async(dep.container_prefix, config)
return {"customer_id": dep.customer_id, "needs_update": cs["needs_update"]} return {"customer_id": dep.customer_id, "needs_update": cs["needs_update"], "unknown": cs.get("unknown", False)}
results = await asyncio.gather(*[_check(dep) for dep in deployments]) results = await asyncio.gather(*[_check(dep) for dep in deployments])
results = list(results) results = list(results)
@@ -274,19 +275,27 @@ async def update_all_customers(
if not to_update: if not to_update:
return {"message": "All customers are already up to date.", "updated": 0, "results": []} return {"message": "All customers are already up to date.", "updated": 0, "results": []}
# Update customers sequentially — one at a time # Update customers sequentially — one at a time. A failure for one
# customer (e.g. a hung docker compose call) must not abort the rest of
# the batch, otherwise later customers silently never get updated.
update_results = [] update_results = []
for entry in to_update: for entry in to_update:
try:
res = await image_service.update_customer_containers( res = await image_service.update_customer_containers(
entry["instance_dir"], entry["project_name"] entry["instance_dir"], entry["project_name"]
) )
ok = res["success"] ok = res["success"]
logger.info("Updated %s: %s", entry["project_name"], "OK" if ok else res.get("error")) error = res.get("error")
except Exception as exc:
logger.exception("Unexpected error updating %s", entry["project_name"])
ok = False
error = str(exc)
logger.info("Updated %s: %s", entry["project_name"], "OK" if ok else error)
update_results.append({ update_results.append({
"customer_name": entry["customer_name"], "customer_name": entry["customer_name"],
"customer_id": entry["customer_id"], "customer_id": entry["customer_id"],
"success": ok, "success": ok,
"error": res.get("error"), "error": error,
}) })
success_count = sum(1 for r in update_results if r["success"]) success_count = sum(1 for r in update_results if r["success"])
+39 -10
View File
@@ -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()
+103 -3
View File
@@ -19,13 +19,38 @@ logger = logging.getLogger(__name__)
NETBIRD_SERVICES = ["management", "signal", "relay", "dashboard"] NETBIRD_SERVICES = ["management", "signal", "relay", "dashboard"]
class _TimeoutResult:
"""Stand-in for subprocess.CompletedProcess when a command times out.
A hung `docker compose up -d` used to raise TimeoutExpired straight out of
_run_cmd, which killed the whole update-all loop mid-recreate and left the
old container renamed-but-not-removed (orphaned with a hash-prefixed name).
Returning a failed result instead lets callers handle it gracefully and
keeps the batch loop going for the remaining customers.
"""
def __init__(self, cmd: list[str], timeout: int):
self.returncode = -1
self.stdout = ""
self.stderr = f"Command timed out after {timeout}s: {' '.join(cmd)}"
async def _run_cmd(cmd: list[str], timeout: int = 300) -> subprocess.CompletedProcess: async def _run_cmd(cmd: list[str], timeout: int = 300) -> subprocess.CompletedProcess:
"""Run a subprocess command without blocking the event loop.""" """Run a subprocess command without blocking the event loop.
Never raises on timeout — returns a failed CompletedProcess-like result
instead, so a single hung docker/compose call can't abort a batch of
otherwise-independent operations (e.g. updating multiple customers).
"""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try:
return await loop.run_in_executor( return await loop.run_in_executor(
None, None,
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout), lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
) )
except subprocess.TimeoutExpired:
logger.error("Command timed out after %ds: %s", timeout, " ".join(cmd))
return _TimeoutResult(cmd, timeout)
def _parse_image_name(image: str) -> tuple[str, str]: def _parse_image_name(image: str) -> tuple[str, str]:
@@ -123,6 +148,60 @@ def get_container_image_id(container_name: str) -> str | None:
return None return None
def repair_container_naming(container_prefix: str, services: list[str] = NETBIRD_SERVICES) -> list[str]:
"""Rename orphaned containers back to their expected compose name.
When a `docker compose up -d` is interrupted mid-recreate (e.g. a timeout
killing the process), Compose can leave the *old* container renamed with a
random hash prefix (e.g. "4e45e71fcb7b_netbird-acme-management") instead
of removing it, while never creating the correctly-named replacement. The
container itself keeps running fine — it's just invisible to every lookup
that expects the exact name, which used to silently read as "no container
found" and get reported as "up to date" instead of "unknown".
This finds any such orphan (a container whose name *contains* the expected
name but isn't an exact match) and, only when no container already holds
the exact expected name, renames it back. Safe no-op otherwise.
Returns the list of service names that were repaired.
"""
repaired = []
for svc in services:
expected_name = f"{container_prefix}-{svc}"
exact = subprocess.run(
["docker", "inspect", expected_name, "--format", "{{.Id}}"],
capture_output=True, text=True, timeout=10,
)
if exact.returncode == 0:
continue # already correctly named
found = subprocess.run(
["docker", "ps", "-a", "--filter", f"name={expected_name}", "--format", "{{.Names}}"],
capture_output=True, text=True, timeout=10,
)
candidates = [n for n in found.stdout.strip().splitlines() if n and n != expected_name]
if not candidates:
continue # container genuinely doesn't exist (not deployed / not running)
orphan = candidates[0]
rename = subprocess.run(
["docker", "rename", orphan, expected_name],
capture_output=True, text=True, timeout=10,
)
if rename.returncode == 0:
logger.warning(
"Repaired orphaned container naming for %s: '%s' -> '%s'",
container_prefix, orphan, expected_name,
)
repaired.append(svc)
else:
logger.error(
"Failed to repair orphaned container '%s' -> '%s': %s",
orphan, expected_name, rename.stderr,
)
return repaired
def get_local_image_id(image: str) -> str | None: def get_local_image_id(image: str) -> str | None:
"""Get the full image ID (sha256:...) of a locally stored image.""" """Get the full image ID (sha256:...) of a locally stored image."""
try: try:
@@ -231,6 +310,13 @@ async def get_customer_container_image_status_async(container_prefix: str, confi
} }
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Self-heal any container left orphaned under a hash-prefixed name by a
# previously interrupted recreate, so the lookups below find it by its
# real, expected name instead of silently returning "not found".
await loop.run_in_executor(
None, repair_container_naming, container_prefix, list(service_images.keys())
)
async def _check(svc: str, image: str) -> tuple[str, dict[str, Any]]: async def _check(svc: str, image: str) -> tuple[str, dict[str, Any]]:
container_name = f"{container_prefix}-{svc}" container_name = f"{container_prefix}-{svc}"
container_id, local_id = await asyncio.gather( container_id, local_id = await asyncio.gather(
@@ -246,7 +332,8 @@ async def get_customer_container_image_status_async(container_prefix: str, confi
pairs = await asyncio.gather(*[_check(svc, image) for svc, image in service_images.items()]) pairs = await asyncio.gather(*[_check(svc, image) for svc, image in service_images.items()])
services = dict(pairs) services = dict(pairs)
needs_update = any(s["up_to_date"] is False for s in services.values()) needs_update = any(s["up_to_date"] is False for s in services.values())
return {"services": services, "needs_update": needs_update} unknown = any(s["up_to_date"] is None for s in services.values())
return {"services": services, "needs_update": needs_update, "unknown": unknown}
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]:
@@ -265,6 +352,11 @@ def get_customer_container_image_status(container_prefix: str, config) -> dict[s
"relay": config.netbird_relay_image, "relay": config.netbird_relay_image,
"dashboard": config.netbird_dashboard_image, "dashboard": config.netbird_dashboard_image,
} }
# Self-heal any container left orphaned under a hash-prefixed name by a
# previously interrupted recreate (see repair_container_naming docstring).
repair_container_naming(container_prefix, list(service_images.keys()))
services: dict[str, Any] = {} services: dict[str, Any] = {}
for svc, image in service_images.items(): for svc, image in service_images.items():
container_name = f"{container_prefix}-{svc}" container_name = f"{container_prefix}-{svc}"
@@ -280,7 +372,8 @@ def get_customer_container_image_status(container_prefix: str, config) -> dict[s
"up_to_date": up_to_date, "up_to_date": up_to_date,
} }
needs_update = any(s["up_to_date"] is False for s in services.values()) needs_update = any(s["up_to_date"] is False for s in services.values())
return {"services": services, "needs_update": needs_update} unknown = any(s["up_to_date"] is None for s in services.values())
return {"services": services, "needs_update": needs_update, "unknown": unknown}
async def update_customer_containers(instance_dir: str, project_name: str) -> dict[str, Any]: async def update_customer_containers(instance_dir: str, project_name: str) -> dict[str, Any]:
@@ -292,6 +385,13 @@ async def update_customer_containers(instance_dir: str, project_name: str) -> di
compose_file = os.path.join(instance_dir, "docker-compose.yml") compose_file = os.path.join(instance_dir, "docker-compose.yml")
if not os.path.isfile(compose_file): if not os.path.isfile(compose_file):
return {"success": False, "error": f"docker-compose.yml not found at {compose_file}"} return {"success": False, "error": f"docker-compose.yml not found at {compose_file}"}
# Repair any container still orphaned under a hash-prefixed name from a
# previous interrupted recreate before Compose tries to touch it again —
# otherwise Compose keeps colliding with the same stuck rename.
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, repair_container_naming, project_name)
cmd = [ cmd = [
"docker", "compose", "docker", "compose",
"-f", compose_file, "-f", compose_file,
+119
View File
@@ -0,0 +1,119 @@
"""Background scheduler for automatic NetBird image update checks.
No external scheduler dependency (APScheduler etc.) — a single asyncio task
started at app startup wakes up once a minute, and only actually does
anything once per day at the configured HH:MM, controlled entirely by
SystemConfig.auto_update_check_enabled / auto_update_check_time.
"""
import asyncio
import logging
from datetime import datetime
from app.database import SessionLocal
from app.models import Deployment, SystemConfig
from app.services import image_service
logger = logging.getLogger(__name__)
_POLL_INTERVAL_SECONDS = 60
_task: asyncio.Task | None = None
def start() -> None:
"""Start the background polling task. Safe to call once at app startup."""
global _task
if _task is None or _task.done():
_task = asyncio.create_task(_poll_loop())
logger.info("Automatic update scheduler started.")
def stop() -> None:
"""Cancel the background polling task."""
global _task
if _task is not None:
_task.cancel()
_task = None
async def _poll_loop() -> None:
while True:
try:
await _tick()
except Exception:
logger.exception("Scheduler tick failed")
await asyncio.sleep(_POLL_INTERVAL_SECONDS)
async def _tick() -> None:
db = SessionLocal()
try:
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not config or not config.auto_update_check_enabled:
return
now = datetime.now()
target_time = config.auto_update_check_time or "03:00"
current_hhmm = now.strftime("%H:%M")
if current_hhmm != target_time:
return
last_run = config.auto_update_last_run_at
if last_run and last_run.date() == now.date():
return # already ran today
# Claim this run immediately so a slow run can't overlap the next tick.
config.auto_update_last_run_at = now
db.commit()
apply_enabled = bool(config.auto_update_apply_enabled)
logger.info(
"Running scheduled NetBird image update check (auto-apply=%s)...", apply_enabled
)
await _run_check_and_optionally_apply(config, apply_enabled)
finally:
db.close()
async def _run_check_and_optionally_apply(config: SystemConfig, apply_enabled: bool) -> None:
hub_status = await image_service.check_all_images(config)
if not hub_status["any_update_available"]:
logger.info("Scheduled check: all NetBird images already up to date.")
return
logger.info("Scheduled check: new NetBird image(s) available — pulling.")
pull_result = await image_service.pull_all_images(config)
if not pull_result["all_success"]:
logger.error("Scheduled image pull had failures: %s", pull_result["results"])
if not apply_enabled:
logger.info("Auto-apply disabled — images pulled, customer containers left untouched.")
return
db = SessionLocal()
try:
deployments = db.query(Deployment).all()
to_update = []
for dep in deployments:
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
if cs["needs_update"]:
customer = dep.customer
to_update.append({
"instance_dir": f"{config.data_dir}/{customer.subdomain}",
"project_name": dep.container_prefix,
"customer_name": customer.name,
})
logger.info("Scheduled auto-apply: updating %d customer(s)...", len(to_update))
for entry in to_update:
try:
res = await image_service.update_customer_containers(
entry["instance_dir"], entry["project_name"]
)
logger.info(
"Scheduled update for %s: %s",
entry["customer_name"], "OK" if res["success"] else res.get("error"),
)
except Exception:
logger.exception("Scheduled update failed for %s", entry["customer_name"])
finally:
db.close()
+42
View File
@@ -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 {
+15
View File
@@ -158,6 +158,21 @@ class SystemConfigUpdate(BaseModel):
git_repo_url: Optional[str] = Field(None, max_length=500) git_repo_url: Optional[str] = Field(None, max_length=500)
git_branch: Optional[str] = Field(None, max_length=100) git_branch: Optional[str] = Field(None, max_length=100)
git_token: Optional[str] = None # plaintext, encrypted before storage git_token: Optional[str] = None # plaintext, encrypted before storage
# Automatic NetBird image update check/apply
auto_update_check_enabled: Optional[bool] = None
auto_update_check_time: Optional[str] = Field(None, max_length=5)
auto_update_apply_enabled: Optional[bool] = None
@field_validator("auto_update_check_time")
@classmethod
def validate_auto_update_check_time(cls, v: Optional[str]) -> Optional[str]:
"""Must be HH:MM in 24h format."""
if v is None:
return v
import re
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", v):
raise ValueError("auto_update_check_time must be in HH:MM 24h format")
return v
@field_validator("ssl_mode") @field_validator("ssl_mode")
@classmethod @classmethod
+20
View File
@@ -1,5 +1,25 @@
/* NetBird MSP Appliance - Custom Styles */ /* NetBird MSP Appliance - Custom Styles */
/* Sortable table headers */
.sortable-th {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.sortable-th:hover {
color: var(--bs-primary);
}
.sortable-th .sort-icon {
opacity: 0.35;
}
.sortable-th.sort-asc .sort-icon,
.sortable-th.sort-desc .sort-icon {
opacity: 1;
}
/* i18n FOUC prevention */ /* i18n FOUC prevention */
body.i18n-loading #login-page, body.i18n-loading #login-page,
body.i18n-loading #app-page { body.i18n-loading #app-page {
+33 -6
View File
@@ -254,13 +254,13 @@
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th data-i18n="dashboard.thId">ID</th> <th class="sortable-th" data-sort-col="id" onclick="setCustomerSort('id')"><span data-i18n="dashboard.thId">ID</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thName">Name</th> <th class="sortable-th" data-sort-col="name" onclick="setCustomerSort('name')"><span data-i18n="dashboard.thName">Name</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thSubdomain">Subdomain</th> <th class="sortable-th" data-sort-col="subdomain" onclick="setCustomerSort('subdomain')"><span data-i18n="dashboard.thSubdomain">Subdomain</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thStatus">Status</th> <th class="sortable-th" data-sort-col="status" onclick="setCustomerSort('status')"><span data-i18n="dashboard.thStatus">Status</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thDashboard">Dashboard</th> <th data-i18n="dashboard.thDashboard">Dashboard</th>
<th data-i18n="dashboard.thDevices">Devices</th> <th class="sortable-th" data-sort-col="max_devices" onclick="setCustomerSort('max_devices')"><span data-i18n="dashboard.thDevices">Devices</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thCreated">Created</th> <th class="sortable-th" data-sort-col="created_at" onclick="setCustomerSort('created_at')"><span data-i18n="dashboard.thCreated">Created</span><i class="bi bi-arrow-down-up sort-icon ms-1"></i></th>
<th data-i18n="dashboard.thActions">Actions</th> <th data-i18n="dashboard.thActions">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -641,6 +641,33 @@
<i class="bi bi-cloud-download me-1"></i><span data-i18n="settings.pullImages">Pull from Docker Hub</span> <i class="bi bi-cloud-download me-1"></i><span data-i18n="settings.pullImages">Pull from Docker Hub</span>
</button> </button>
<span id="pull-images-settings-status" class="ms-2 text-muted small"></span> <span id="pull-images-settings-status" class="ms-2 text-muted small"></span>
<hr>
<h6 data-i18n="monitoring.autoUpdateTitle">Automatic Updates</h6>
<p class="text-muted small" data-i18n="monitoring.autoUpdateHint">Automatically checks for new NetBird images daily at the chosen time.</p>
<form id="settings-auto-update-form">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="cfg-auto-update-check-enabled">
<label class="form-check-label" for="cfg-auto-update-check-enabled" data-i18n="monitoring.autoUpdateCheckEnabled">Enable automatic update check</label>
</div>
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label" data-i18n="monitoring.autoUpdateCheckTime">Daily check time</label>
<input type="time" class="form-control" id="cfg-auto-update-check-time" value="03:00">
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="cfg-auto-update-apply-enabled">
<label class="form-check-label" for="cfg-auto-update-apply-enabled" data-i18n="monitoring.autoUpdateApplyEnabled">Automatically update customer containers after check</label>
<div class="form-text" data-i18n="monitoring.autoUpdateApplyHint">When enabled, all customer containers are automatically recreated after a new image is found (services briefly restart). When disabled, only the images are pulled — updating customers stays a manual step.</div>
</div>
<div class="mt-3 small text-muted">
<span data-i18n="monitoring.autoUpdateLastRun">Last automatic check</span>:
<span id="auto-update-last-run">-</span>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-save me-1"></i><span data-i18n="monitoring.saveAutoUpdateSettings">Save Automation</span></button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
+117 -3
View File
@@ -12,6 +12,8 @@ let currentPage = 'dashboard';
let currentCustomerId = null; let currentCustomerId = null;
let currentCustomerData = null; let currentCustomerData = null;
let customersPage = 1; let customersPage = 1;
let customersSortBy = 'id';
let customersSortOrder = 'asc';
let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null, version: 'alpha-1.1' }; let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null, version: 'alpha-1.1' };
let azureConfig = { azure_enabled: false }; let azureConfig = { azure_enabled: false };
@@ -458,7 +460,7 @@ async function loadStats() {
async function loadCustomers() { async function loadCustomers() {
const search = document.getElementById('search-input').value; const search = document.getElementById('search-input').value;
const status = document.getElementById('status-filter').value; const status = document.getElementById('status-filter').value;
let url = `/customers?page=${customersPage}&per_page=25`; let url = `/customers?page=${customersPage}&per_page=25&sort_by=${customersSortBy}&sort_order=${customersSortOrder}`;
if (search) url += `&search=${encodeURIComponent(search)}`; if (search) url += `&search=${encodeURIComponent(search)}`;
if (status) url += `&status=${encodeURIComponent(status)}`; if (status) url += `&status=${encodeURIComponent(status)}`;
@@ -470,7 +472,32 @@ async function loadCustomers() {
} }
} }
function setCustomerSort(column) {
if (customersSortBy === column) {
customersSortOrder = customersSortOrder === 'asc' ? 'desc' : 'asc';
} else {
customersSortBy = column;
customersSortOrder = 'asc';
}
customersPage = 1;
loadCustomers();
}
function updateSortHeaders() {
document.querySelectorAll('.sortable-th').forEach(th => {
const col = th.getAttribute('data-sort-col');
const icon = th.querySelector('.sort-icon');
th.classList.remove('sort-asc', 'sort-desc');
if (icon) icon.className = 'bi bi-arrow-down-up sort-icon ms-1';
if (col === customersSortBy) {
th.classList.add(customersSortOrder === 'asc' ? 'sort-asc' : 'sort-desc');
if (icon) icon.className = `bi bi-arrow-${customersSortOrder === 'asc' ? 'up' : 'down'} sort-icon ms-1`;
}
});
}
function renderCustomersTable(data) { function renderCustomersTable(data) {
updateSortHeaders();
const tbody = document.getElementById('customers-table-body'); const tbody = document.getElementById('customers-table-body');
if (!data.items || data.items.length === 0) { if (!data.items || data.items.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`; tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`;
@@ -913,6 +940,13 @@ async function loadSettings() {
document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || ''; document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || '';
document.getElementById('cfg-dashboard-image').value = cfg.netbird_dashboard_image || ''; document.getElementById('cfg-dashboard-image').value = cfg.netbird_dashboard_image || '';
document.getElementById('cfg-auto-update-check-enabled').checked = cfg.auto_update_check_enabled || false;
document.getElementById('cfg-auto-update-check-time').value = cfg.auto_update_check_time || '03:00';
document.getElementById('cfg-auto-update-apply-enabled').checked = cfg.auto_update_apply_enabled || false;
document.getElementById('auto-update-last-run').textContent = cfg.auto_update_last_run_at
? new Date(cfg.auto_update_last_run_at).toLocaleString()
: t('monitoring.autoUpdateNever');
// Branding tab // Branding tab
document.getElementById('cfg-branding-name').value = cfg.branding_name || ''; document.getElementById('cfg-branding-name').value = cfg.branding_name || '';
document.getElementById('cfg-branding-subtitle').value = cfg.branding_subtitle || ''; document.getElementById('cfg-branding-subtitle').value = cfg.branding_subtitle || '';
@@ -1031,6 +1065,21 @@ document.getElementById('settings-images-form').addEventListener('submit', async
} }
}); });
// Automatic update settings form
document.getElementById('settings-auto-update-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PUT', '/settings/system', {
auto_update_check_enabled: document.getElementById('cfg-auto-update-check-enabled').checked,
auto_update_check_time: document.getElementById('cfg-auto-update-check-time').value || '03:00',
auto_update_apply_enabled: document.getElementById('cfg-auto-update-apply-enabled').checked,
});
showSettingsAlert('success', t('messages.imageSettingsSaved'));
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
// Test NPM connection // Test NPM connection
async function testNpmConnection() { async function testNpmConnection() {
const spinner = document.getElementById('npm-test-spinner'); const spinner = document.getElementById('npm-test-spinner');
@@ -1374,11 +1423,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 +1440,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'));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1717,6 +1829,8 @@ async function checkImageUpdates() {
: data.customer_status.map(c => { : data.customer_status.map(c => {
const badge = c.needs_update const badge = c.needs_update
? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>` ? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>`
: c.unknown
? `<span class="badge bg-secondary" title="${t('monitoring.statusUnknownHint')}">${t('monitoring.statusUnknown')}</span>`
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`; : `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
const updateBtn = c.needs_update const updateBtn = c.needs_update
? `<button class="btn btn-sm btn-outline-warning ms-2 btn-update-customer" onclick="updateCustomerImages(${c.customer_id})" ? `<button class="btn btn-sm btn-outline-warning ms-2 btn-update-customer" onclick="updateCustomerImages(${c.customer_id})"
+20 -1
View File
@@ -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.",
@@ -412,6 +420,17 @@
"updating": "Wird aktualisiert…", "updating": "Wird aktualisiert…",
"updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…", "updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…",
"pulling": "Wird geladen…", "pulling": "Wird geladen…",
"pullStartedShort": "Download im Hintergrund gestartet." "pullStartedShort": "Download im Hintergrund gestartet.",
"statusUnknown": "Unbekannt",
"statusUnknownHint": "Container konnte nicht eindeutig zugeordnet werden (z. B. nicht gestartet). Kein verifiziertes \"Aktuell\".",
"autoUpdateTitle": "Automatische Aktualisierung",
"autoUpdateHint": "Prüft täglich zur gewählten Uhrzeit automatisch auf neue NetBird-Images.",
"autoUpdateCheckEnabled": "Automatische Update-Prüfung aktivieren",
"autoUpdateCheckTime": "Uhrzeit der täglichen Prüfung",
"autoUpdateApplyEnabled": "Kunden-Container nach Prüfung automatisch aktualisieren",
"autoUpdateApplyHint": "Wenn aktiviert, werden nach einer gefundenen Aktualisierung automatisch alle Kunden-Container neu erstellt (kurzer Neustart der Dienste). Wenn deaktiviert, werden nur die Images geladen — die Aktualisierung der Kunden erfolgt weiterhin manuell.",
"autoUpdateLastRun": "Letzte automatische Prüfung",
"autoUpdateNever": "Noch nie ausgeführt",
"saveAutoUpdateSettings": "Automatisierung speichern"
} }
} }
+20 -1
View File
@@ -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.",
@@ -319,7 +327,18 @@
"updating": "Updating…", "updating": "Updating…",
"updateAllProgress": "Updating customer containers one by one — please wait…", "updateAllProgress": "Updating customer containers one by one — please wait…",
"pulling": "Pulling…", "pulling": "Pulling…",
"pullStartedShort": "Pull started in background." "pullStartedShort": "Pull started in background.",
"statusUnknown": "Unknown",
"statusUnknownHint": "Container could not be matched reliably (e.g. not running). Not a verified \"up to date\".",
"autoUpdateTitle": "Automatic Updates",
"autoUpdateHint": "Automatically checks for new NetBird images daily at the chosen time.",
"autoUpdateCheckEnabled": "Enable automatic update check",
"autoUpdateCheckTime": "Daily check time",
"autoUpdateApplyEnabled": "Automatically update customer containers after check",
"autoUpdateApplyHint": "When enabled, all customer containers are automatically recreated after a new image is found (services briefly restart). When disabled, only the images are pulled — updating customers stays a manual step.",
"autoUpdateLastRun": "Last automatic check",
"autoUpdateNever": "Never run",
"saveAutoUpdateSettings": "Save Automation"
}, },
"userModal": { "userModal": {
"title": "New User", "title": "New User",