Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51fbd44809 | ||
|
|
e53539231e | ||
|
|
0e38b8083c | ||
|
|
0e2b292408 | ||
|
|
a5988af6a3 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user