Compare commits
30
Commits
alpha-1.17
...
v1.3.0
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -38,11 +39,21 @@ A management solution for running isolated NetBird instances for your MSP busine
|
|||||||
- **Docker-Based** — Everything runs in containers for easy deployment
|
- **Docker-Based** — Everything runs in containers for easy deployment
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
- **Modern Web UI** — Responsive Bootstrap 5 interface
|
- **Modern Web UI** — Responsive Bootstrap 5 interface with dark/light mode toggle
|
||||||
- **Real-Time Monitoring** — Container status, health checks, resource usage
|
- **Real-Time Monitoring** — Container status, health checks, resource usage
|
||||||
- **Container Logs** — View logs per container directly in the browser
|
- **Container Logs** — View logs per container directly in the browser
|
||||||
- **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
|
||||||
|
- **Sortable Columns** — Click any customer list column header (ID, Name, Subdomain, Status, Devices, Created) to sort ascending/descending
|
||||||
|
|
||||||
|
### NetBird Container Updates
|
||||||
|
- **Docker Hub Digest Check** — Compare locally pulled image digests against Docker Hub without pulling
|
||||||
|
- **One-Click Pull** — Pull all NetBird images from Docker Hub via Settings
|
||||||
|
- **Bulk Update** — Update all outdated customer containers at once from the Monitoring page
|
||||||
|
- **Per-Customer Update** — Update a single customer's containers from the customer detail view
|
||||||
|
- **Zero Data Loss** — Container recreation preserves all bind-mounted volumes
|
||||||
|
- **Sequential Updates** — Customers are updated one at a time to minimize risk
|
||||||
|
|
||||||
### Multi-Language (i18n)
|
### Multi-Language (i18n)
|
||||||
- **English and German** — Full UI translation
|
- **English and German** — Full UI translation
|
||||||
@@ -55,13 +66,18 @@ A management solution for running isolated NetBird instances for your MSP busine
|
|||||||
- **Login Page** — Branding is applied to the login page automatically
|
- **Login Page** — Branding is applied to the login page automatically
|
||||||
- **Configurable Docker Images** — Use custom or specific NetBird image versions
|
- **Configurable Docker Images** — Use custom or specific NetBird image versions
|
||||||
|
|
||||||
### Security
|
### Authentication & User Management
|
||||||
- **JWT Authentication** — Token-based API authentication
|
- **JWT Authentication** — Token-based API authentication
|
||||||
- **Multi-Factor Authentication (MFA)** — Optional TOTP-based MFA for all local users, activatable in Security settings
|
- **Multi-Factor Authentication (MFA)** — Optional TOTP-based MFA for all local users, activatable in Security settings
|
||||||
- **Azure AD / OIDC** — Optional single sign-on via Microsoft Entra ID (exempt from MFA)
|
- **Azure AD / OIDC** — Optional single sign-on via Microsoft Entra ID (exempt from MFA)
|
||||||
- **Encrypted Credentials** — NPM passwords, relay secrets, and TOTP secrets are Fernet-encrypted at rest
|
- **LDAP / Active Directory** — Allow AD users to authenticate; local admin accounts always work as fallback
|
||||||
|
- **Encrypted Credentials** — NPM passwords, relay secrets, TOTP secrets, and LDAP bind passwords are Fernet-encrypted at rest
|
||||||
- **User Management** — Create, edit, delete admin users, reset passwords and MFA
|
- **User Management** — Create, edit, delete admin users, reset passwords and MFA
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
- **Windows DNS** — Automatically create and delete DNS A-records when deploying or removing customers
|
||||||
|
- **MSP Updates** — In-UI appliance version check, configurable release branch, and one-click background update with live progress
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -178,6 +194,18 @@ The following tools and services must be available **before** running the instal
|
|||||||
|
|
||||||
### Install Prerequisites (Ubuntu/Debian)
|
### Install Prerequisites (Ubuntu/Debian)
|
||||||
|
|
||||||
|
> **Note:** On a fresh Debian minimal install, `sudo` is not pre-installed. Install it as root first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# As root — only needed on fresh Debian minimal (sudo not pre-installed):
|
||||||
|
apt update && apt install -y sudo
|
||||||
|
|
||||||
|
# Install remaining prerequisites:
|
||||||
|
sudo apt install -y curl git openssl
|
||||||
|
```
|
||||||
|
|
||||||
|
If `sudo` is already available (Ubuntu, most standard installs):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y curl git openssl
|
sudo apt install -y curl git openssl
|
||||||
@@ -266,17 +294,32 @@ HOST_IP=<your-server-ip>
|
|||||||
|
|
||||||
### Web UI Settings
|
### Web UI Settings
|
||||||
|
|
||||||
Available under **Settings** in the web interface:
|
Available under **Settings** in the web interface, organized into tabs:
|
||||||
|
|
||||||
|
#### User Management
|
||||||
|
|
||||||
| Tab | Settings |
|
| Tab | Settings |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| **System** | Base domain, admin email, Docker images, port ranges, data directory |
|
| **Azure AD** | Azure AD / Entra ID SSO configuration (tenant ID, client ID/secret, optional group restriction) |
|
||||||
| **NPM Integration** | NPM API URL, login credentials, SSL certificate mode (Let's Encrypt / Wildcard), wildcard certificate selection |
|
|
||||||
| **Branding** | Platform name, subtitle, logo upload, default language |
|
|
||||||
| **Users** | Create/edit/delete admin users, per-user language preference, MFA reset |
|
| **Users** | Create/edit/delete admin users, per-user language preference, MFA reset |
|
||||||
| **Azure AD** | Azure AD / Entra ID SSO configuration |
|
| **LDAP / AD** | LDAP/Active Directory authentication (server, base DN, bind credentials, group restriction), enable/disable |
|
||||||
| **Security** | Change admin password, enable/disable MFA globally, manage own TOTP |
|
| **Security** | Change admin password, enable/disable MFA globally, manage own TOTP |
|
||||||
| **Monitoring** | System resources, Docker stats |
|
|
||||||
|
#### System
|
||||||
|
|
||||||
|
| Tab | Settings |
|
||||||
|
|-----|----------|
|
||||||
|
| **Branding** | Platform name, subtitle, logo upload, default language |
|
||||||
|
| **NetBird Docker Images** | Configured NetBird image tags (management, signal, relay, dashboard), pull images from Docker Hub |
|
||||||
|
| **NetBird MSP System** | Base domain, admin email, port ranges, data directory |
|
||||||
|
| **NetBird MSP Updates** | Appliance version info, check for updates, switch release branch |
|
||||||
|
|
||||||
|
#### External Systems
|
||||||
|
|
||||||
|
| Tab | Settings |
|
||||||
|
|-----|----------|
|
||||||
|
| **NPM Proxy** | NPM API URL, login credentials, SSL certificate mode (Let's Encrypt / Wildcard), wildcard certificate selection |
|
||||||
|
| **Windows DNS** | Windows DNS server integration for automatic DNS A-record creation/deletion on customer deploy/delete |
|
||||||
|
|
||||||
Changes are applied immediately without restart.
|
Changes are applied immediately without restart.
|
||||||
|
|
||||||
@@ -308,11 +351,42 @@ Changes are applied immediately without restart.
|
|||||||
|
|
||||||
### Monitoring
|
### Monitoring
|
||||||
|
|
||||||
The dashboard shows:
|
The **Monitoring** page shows:
|
||||||
- **System Overview** — Total customers, active/inactive, errors
|
- **System Overview** — Total customers, active/inactive, errors
|
||||||
- **Resource Usage** — RAM, CPU per container
|
- **Host Resources** — CPU, RAM, disk usage of the host machine
|
||||||
- **Container Health** — Running/stopped per container with color-coded status
|
- **Customer Status** — Container health per customer (running/stopped)
|
||||||
- **Deployment Logs** — Action history per customer
|
- **NetBird Container Updates** — Compare local image digests against Docker Hub, pull new images, and update all outdated customer containers
|
||||||
|
|
||||||
|
### NetBird Container Updates
|
||||||
|
|
||||||
|
#### Workflow
|
||||||
|
|
||||||
|
1. **Check for updates** — Go to **Monitoring > NetBird Container Updates**, click **"Check Updates"**
|
||||||
|
- Compares local image digests against Docker Hub
|
||||||
|
- Shows which images have a new version available
|
||||||
|
- Shows which customer containers are running outdated images
|
||||||
|
- An orange badge appears next to customers in the dashboard list that need updating
|
||||||
|
|
||||||
|
2. **Pull new images** — Go to **Settings > NetBird Docker Images**, click **"Pull from Docker Hub"**
|
||||||
|
- Pulls all 4 NetBird images (`management`, `signal`, `relay`, `dashboard`) in the background
|
||||||
|
- Wait for the pull to complete before updating customers
|
||||||
|
|
||||||
|
3. **Update customers** — Return to **Monitoring > NetBird Container Updates**, click **"Update All Customers"**
|
||||||
|
- Recreates containers for all customers whose running image is outdated
|
||||||
|
- Customers are updated **sequentially** — one at a time
|
||||||
|
- All bind-mounted volumes (database, keys, config) are preserved — **no data loss**
|
||||||
|
- A per-customer results table is shown after completion
|
||||||
|
|
||||||
|
#### Per-Customer Update
|
||||||
|
|
||||||
|
To update a single customer:
|
||||||
|
1. Open the customer detail view
|
||||||
|
2. Go to the **Deployment** tab
|
||||||
|
3. Click **"Update Images"**
|
||||||
|
|
||||||
|
#### Update Badges
|
||||||
|
|
||||||
|
The dashboard customer list shows an orange **"Update"** badge next to any customer whose running containers are using an outdated local image. This check is fast (local-only, no network call) and runs automatically when the dashboard loads.
|
||||||
|
|
||||||
### Language Settings
|
### Language Settings
|
||||||
|
|
||||||
@@ -320,9 +394,13 @@ The dashboard shows:
|
|||||||
- **Per-user default** — Set in Settings > Users during user creation
|
- **Per-user default** — Set in Settings > Users during user creation
|
||||||
- **System default** — Set in Settings > Branding
|
- **System default** — Set in Settings > Branding
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
|
||||||
|
Toggle dark/light mode using the moon/sun icon in the top navigation bar. The preference is saved in the browser.
|
||||||
|
|
||||||
### Multi-Factor Authentication (MFA)
|
### Multi-Factor Authentication (MFA)
|
||||||
|
|
||||||
TOTP-based MFA can be enabled globally for all local users. Azure AD users are not affected (they use their own MFA).
|
TOTP-based MFA can be enabled globally for all local users. Azure AD and LDAP users are not affected (they use their own authentication systems).
|
||||||
|
|
||||||
#### Enable MFA
|
#### Enable MFA
|
||||||
1. Go to **Settings > Security**
|
1. Go to **Settings > Security**
|
||||||
@@ -344,9 +422,30 @@ When MFA is enabled and a user logs in for the first time:
|
|||||||
- **Disable own TOTP** — In Settings > Security, click "Disable my TOTP" to remove your own MFA setup
|
- **Disable own TOTP** — In Settings > Security, click "Disable my TOTP" to remove your own MFA setup
|
||||||
- **Disable MFA globally** — Uncheck the toggle in Settings > Security to allow login without MFA
|
- **Disable MFA globally** — Uncheck the toggle in Settings > Security to allow login without MFA
|
||||||
|
|
||||||
|
### LDAP / Active Directory Authentication
|
||||||
|
|
||||||
|
Active Directory users can log in to the appliance using their AD credentials. Local admin accounts always work as a fallback regardless of LDAP status.
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
1. Go to **Settings > LDAP / AD**
|
||||||
|
2. Enable **"LDAP / AD Authentication"**
|
||||||
|
3. Enter LDAP server, port, bind DN (service account), bind password, and base DN
|
||||||
|
4. Optionally restrict access to members of a specific AD group
|
||||||
|
5. Click **Save LDAP Settings**
|
||||||
|
|
||||||
|
### Windows DNS Integration
|
||||||
|
|
||||||
|
Automatically create and delete DNS A-records in a Windows DNS server when customers are deployed or deleted.
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
1. Go to **Settings > Windows DNS**
|
||||||
|
2. Enable **"Windows DNS Integration"**
|
||||||
|
3. Enter the DNS server details
|
||||||
|
4. Click **Save DNS Settings**
|
||||||
|
|
||||||
### SSL Certificate Mode
|
### SSL Certificate Mode
|
||||||
|
|
||||||
The appliance supports two SSL certificate modes for customer proxy hosts, configurable under **Settings > NPM Integration**:
|
The appliance supports two SSL certificate modes for customer proxy hosts, configurable under **Settings > NPM Proxy**:
|
||||||
|
|
||||||
#### Let's Encrypt (default)
|
#### Let's Encrypt (default)
|
||||||
Each customer gets an individual Let's Encrypt certificate via HTTP-01 validation. This is the default behavior and requires no additional setup beyond a valid admin email.
|
Each customer gets an individual Let's Encrypt certificate via HTTP-01 validation. This is the default behavior and requires no additional setup beyond a valid admin email.
|
||||||
@@ -356,7 +455,7 @@ Use a pre-existing wildcard certificate (e.g. `*.yourdomain.com`) already upload
|
|||||||
|
|
||||||
**Setup:**
|
**Setup:**
|
||||||
1. Upload a wildcard certificate in Nginx Proxy Manager (e.g. via DNS challenge)
|
1. Upload a wildcard certificate in Nginx Proxy Manager (e.g. via DNS challenge)
|
||||||
2. Go to **Settings > NPM Integration**
|
2. Go to **Settings > NPM Proxy**
|
||||||
3. Set **SSL Mode** to "Wildcard Certificate"
|
3. Set **SSL Mode** to "Wildcard Certificate"
|
||||||
4. Click the refresh button to load certificates from NPM
|
4. Click the refresh button to load certificates from NPM
|
||||||
5. Select your wildcard certificate from the dropdown
|
5. Select your wildcard certificate from the dropdown
|
||||||
@@ -385,30 +484,40 @@ 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
|
||||||
|
|
||||||
POST /api/customers/{id}/start # Start containers
|
POST /api/customers/{id}/start # Start containers
|
||||||
POST /api/customers/{id}/stop # Stop containers
|
POST /api/customers/{id}/stop # Stop containers
|
||||||
POST /api/customers/{id}/restart # Restart containers
|
POST /api/customers/{id}/restart # Restart containers
|
||||||
GET /api/customers/{id}/logs # Get container logs
|
GET /api/customers/{id}/logs # Get container logs
|
||||||
GET /api/customers/{id}/health # Health check
|
GET /api/customers/{id}/health # Health check
|
||||||
|
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/users # List users
|
GET /api/settings/version # Current + latest available appliance version
|
||||||
POST /api/users # Create user
|
POST /api/settings/update # Start appliance update in the background
|
||||||
POST /api/users/{id}/reset-mfa # Reset user's MFA
|
GET /api/settings/update/status # Poll update progress (backup/pull/build/restart)
|
||||||
|
|
||||||
POST /api/auth/mfa/setup # Generate TOTP secret + QR code
|
GET /api/users # List users
|
||||||
POST /api/auth/mfa/setup/complete # Verify first TOTP code
|
POST /api/users # Create user
|
||||||
POST /api/auth/mfa/verify # Verify TOTP code on login
|
POST /api/users/{id}/reset-mfa # Reset user's MFA
|
||||||
GET /api/auth/mfa/status # Get MFA status
|
|
||||||
POST /api/auth/mfa/disable # Disable own TOTP
|
POST /api/auth/mfa/setup # Generate TOTP secret + QR code
|
||||||
|
POST /api/auth/mfa/setup/complete # Verify first TOTP code
|
||||||
|
POST /api/auth/mfa/verify # Verify TOTP code on login
|
||||||
|
GET /api/auth/mfa/status # Get MFA status
|
||||||
|
POST /api/auth/mfa/disable # Disable own TOTP
|
||||||
|
|
||||||
|
GET /api/monitoring/images/check # Check Hub vs local digests for all images
|
||||||
|
POST /api/monitoring/images/pull # Pull all NetBird images from Docker Hub (background)
|
||||||
|
GET /api/monitoring/customers/local-update-status # Fast local-only update check (no network)
|
||||||
|
POST /api/monitoring/customers/update-all # Recreate outdated containers for all customers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example: Create Customer via API
|
### Example: Create Customer via API
|
||||||
@@ -477,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
|
||||||
@@ -484,15 +602,32 @@ 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
|
||||||
|
|
||||||
Via the Web UI:
|
NetBird image updates are managed entirely through the Web UI — no manual config changes required.
|
||||||
1. Settings > System Configuration
|
|
||||||
2. Change image tags (e.g., `netbirdio/management:0.35.0`)
|
#### Step 1 — Pull new images
|
||||||
3. Click "Save"
|
|
||||||
4. Re-deploy individual customers to apply the new images
|
1. Go to **Settings > NetBird Docker Images**
|
||||||
|
2. Click **"Pull from Docker Hub"**
|
||||||
|
3. Wait for the pull to complete (progress shown inline)
|
||||||
|
|
||||||
|
#### Step 2 — Check which customers need updating
|
||||||
|
|
||||||
|
1. Go to **Monitoring > NetBird Container Updates**
|
||||||
|
2. Click **"Check Updates"**
|
||||||
|
3. The table shows per-image Hub vs. local digest comparison and which customers are running outdated containers
|
||||||
|
|
||||||
|
#### Step 3 — Update customer containers
|
||||||
|
|
||||||
|
- **All customers**: Click **"Update All Customers"** in the Monitoring page
|
||||||
|
- Customers are updated sequentially, one at a time
|
||||||
|
- A results table is shown after completion
|
||||||
|
- **Single customer**: Open the customer detail view > **Deployment** tab > **"Update Images"**
|
||||||
|
|
||||||
|
> All bind-mounted volumes (database, keys, config files) are preserved. Container recreation does not cause data loss.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -546,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 Opus 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):
|
||||||
|
|||||||
+35
-7
@@ -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.0.0",
|
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",
|
||||||
@@ -90,16 +91,36 @@ STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
|
|||||||
if os.path.isdir(STATIC_DIR):
|
if os.path.isdir(STATIC_DIR):
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
# Serve index.html at root
|
# Serve index.html at root — inject cache-busting version into static asset URLs
|
||||||
from fastapi.responses import FileResponse
|
# so the browser always loads fresh JS/CSS after a container update.
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
|
from app.services import update_service
|
||||||
|
|
||||||
|
_STATIC_ASSETS = (
|
||||||
|
'"/static/js/app.js"',
|
||||||
|
'"/static/js/i18n.js"',
|
||||||
|
'"/static/css/styles.css"',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cache_bust_index(html: str, version: str) -> str:
|
||||||
|
# Inject version as a global JS variable so i18n.js can bust lang file caches too
|
||||||
|
html = html.replace("</head>", f'<script>window.STATIC_VERSION="{version}";</script>\n</head>', 1)
|
||||||
|
for asset in _STATIC_ASSETS:
|
||||||
|
busted = asset.rstrip('"') + f'?v={version}"'
|
||||||
|
html = html.replace(asset, busted)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
async def serve_index():
|
async def serve_index():
|
||||||
"""Serve the main dashboard."""
|
"""Serve the main dashboard with cache-busted static asset URLs."""
|
||||||
index_path = os.path.join(STATIC_DIR, "index.html")
|
index_path = os.path.join(STATIC_DIR, "index.html")
|
||||||
if os.path.isfile(index_path):
|
if not os.path.isfile(index_path):
|
||||||
return FileResponse(index_path)
|
return JSONResponse({"message": "NetBird MSP Appliance API is running."})
|
||||||
return JSONResponse({"message": "NetBird MSP Appliance API is running."})
|
version = update_service.get_current_version().get("commit", "unknown")
|
||||||
|
html = open(index_path, encoding="utf-8").read()
|
||||||
|
html = _cache_bust_index(html, version)
|
||||||
|
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -120,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()
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import SessionLocal, get_db
|
from app.database import SessionLocal, get_db
|
||||||
from app.dependencies import get_current_user
|
from app.dependencies import get_current_user
|
||||||
from app.models import Customer, Deployment, User
|
from app.models import Customer, Deployment, SystemConfig, User
|
||||||
from app.services import docker_service, netbird_service
|
from app.services import docker_service, image_service, netbird_service
|
||||||
from app.utils.security import decrypt_value
|
from app.utils.security import decrypt_value
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -19,6 +19,14 @@ router = APIRouter()
|
|||||||
async def manual_deploy(
|
async def manual_deploy(
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
keep_data: bool = Query(
|
||||||
|
False,
|
||||||
|
description=(
|
||||||
|
"If True, preserve existing NetBird data (database, keys, peers). "
|
||||||
|
"Containers are recreated without wiping the instance directory. "
|
||||||
|
"If False (default), the instance is fully removed and redeployed from scratch."
|
||||||
|
),
|
||||||
|
),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -29,6 +37,7 @@ async def manual_deploy(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_id: Customer ID.
|
customer_id: Customer ID.
|
||||||
|
keep_data: Whether to preserve existing NetBird data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Acknowledgement dict.
|
Acknowledgement dict.
|
||||||
@@ -40,12 +49,12 @@ async def manual_deploy(
|
|||||||
customer.status = "deploying"
|
customer.status = "deploying"
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
async def _deploy_bg(cid: int) -> None:
|
async def _deploy_bg(cid: int, keep: bool) -> None:
|
||||||
bg_db = SessionLocal()
|
bg_db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# Remove existing deployment if present
|
|
||||||
existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first()
|
existing = bg_db.query(Deployment).filter(Deployment.customer_id == cid).first()
|
||||||
if existing:
|
if existing and not keep:
|
||||||
|
# Full redeploy: remove everything first
|
||||||
await netbird_service.undeploy_customer(bg_db, cid)
|
await netbird_service.undeploy_customer(bg_db, cid)
|
||||||
await netbird_service.deploy_customer(bg_db, cid)
|
await netbird_service.deploy_customer(bg_db, cid)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -53,7 +62,7 @@ async def manual_deploy(
|
|||||||
finally:
|
finally:
|
||||||
bg_db.close()
|
bg_db.close()
|
||||||
|
|
||||||
background_tasks.add_task(_deploy_bg, customer_id)
|
background_tasks.add_task(_deploy_bg, customer_id, keep_data)
|
||||||
return {"message": "Deployment started in background.", "status": "deploying"}
|
return {"message": "Deployment started in background.", "status": "deploying"}
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +216,50 @@ async def get_customer_credentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{customer_id}/update-images")
|
||||||
|
async def update_customer_images(
|
||||||
|
customer_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Recreate a customer's containers to pick up newly pulled images.
|
||||||
|
|
||||||
|
Images must already be pulled via POST /monitoring/images/pull.
|
||||||
|
Bind-mounted data is preserved — no data loss.
|
||||||
|
"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
|
||||||
|
|
||||||
|
customer = _require_customer(db, customer_id)
|
||||||
|
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||||
|
if not deployment:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No deployment found for this customer.",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured."
|
||||||
|
)
|
||||||
|
|
||||||
|
instance_dir = f"{config.data_dir}/{customer.subdomain}"
|
||||||
|
result = await image_service.update_customer_containers(instance_dir, deployment.container_prefix)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=result.get("error", "Failed to update containers."),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Containers updated for customer '%s' (prefix: %s) by '%s'.",
|
||||||
|
customer.name, deployment.container_prefix, current_user.username,
|
||||||
|
)
|
||||||
|
return {"message": f"Containers updated for '{customer.name}'."}
|
||||||
|
|
||||||
|
|
||||||
def _require_customer(db: Session, customer_id: int) -> Customer:
|
def _require_customer(db: Session, customer_id: int) -> Customer:
|
||||||
"""Helper to fetch a customer or raise 404.
|
"""Helper to fetch a customer or raise 404.
|
||||||
|
|
||||||
|
|||||||
+198
-9
@@ -1,21 +1,30 @@
|
|||||||
"""Monitoring API — system overview, customer statuses, host resources."""
|
"""Monitoring API — system overview, customer statuses, host resources."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import platform
|
import platform
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import SessionLocal, get_db
|
||||||
from app.dependencies import get_current_user
|
from app.dependencies import get_current_user
|
||||||
from app.models import Customer, Deployment, User
|
from app.models import Customer, Deployment, SystemConfig, User
|
||||||
from app.services import docker_service
|
from app.services import docker_service, image_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Short-lived cache for the local update-status badges. This endpoint is
|
||||||
|
# triggered on every customer-table render (i.e. every search keystroke), but
|
||||||
|
# the underlying data (which images are outdated) only changes after an image
|
||||||
|
# pull + container recreate, so a few seconds of staleness is harmless.
|
||||||
|
_update_status_cache: dict[str, Any] = {"data": None, "expires": 0.0}
|
||||||
|
_UPDATE_STATUS_TTL_SECONDS = 20
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def system_status(
|
async def system_status(
|
||||||
@@ -58,8 +67,7 @@ async def all_customers_status(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
async def _build_entry(c: Customer) -> dict[str, Any]:
|
||||||
for c in customers:
|
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
"name": c.name,
|
"name": c.name,
|
||||||
@@ -67,7 +75,7 @@ async def all_customers_status(
|
|||||||
"status": c.status,
|
"status": c.status,
|
||||||
}
|
}
|
||||||
if c.deployment:
|
if c.deployment:
|
||||||
containers = docker_service.get_container_status(c.deployment.container_prefix)
|
containers = await docker_service.get_container_status_async(c.deployment.container_prefix)
|
||||||
entry["deployment_status"] = c.deployment.deployment_status
|
entry["deployment_status"] = c.deployment.deployment_status
|
||||||
entry["containers"] = containers
|
entry["containers"] = containers
|
||||||
entry["relay_udp_port"] = c.deployment.relay_udp_port
|
entry["relay_udp_port"] = c.deployment.relay_udp_port
|
||||||
@@ -76,9 +84,11 @@ async def all_customers_status(
|
|||||||
else:
|
else:
|
||||||
entry["deployment_status"] = None
|
entry["deployment_status"] = None
|
||||||
entry["containers"] = []
|
entry["containers"] = []
|
||||||
results.append(entry)
|
return entry
|
||||||
|
|
||||||
return results
|
# Fetch container status for all customers concurrently instead of one
|
||||||
|
# blocking Docker SDK call at a time.
|
||||||
|
return await asyncio.gather(*[_build_entry(c) for c in customers])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/resources")
|
@router.get("/resources")
|
||||||
@@ -115,3 +125,182 @@ async def host_resources(
|
|||||||
"percent": disk.percent,
|
"percent": disk.percent,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/images/check")
|
||||||
|
async def check_image_updates(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Check all configured NetBird images for available updates on Docker Hub.
|
||||||
|
|
||||||
|
Compares local image digests against Docker Hub — no image is pulled.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
images: dict mapping image name to update status
|
||||||
|
any_update_available: bool
|
||||||
|
customer_status: list of per-customer container image status
|
||||||
|
"""
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
|
||||||
|
|
||||||
|
hub_status = await image_service.check_all_images(config)
|
||||||
|
|
||||||
|
# Per-customer local check (no network)
|
||||||
|
deployments = db.query(Deployment).all()
|
||||||
|
customer_status = []
|
||||||
|
for dep in deployments:
|
||||||
|
customer = dep.customer
|
||||||
|
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
|
||||||
|
customer_status.append({
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"customer_name": customer.name,
|
||||||
|
"subdomain": customer.subdomain,
|
||||||
|
"container_prefix": dep.container_prefix,
|
||||||
|
"needs_update": cs["needs_update"],
|
||||||
|
"unknown": cs.get("unknown", False),
|
||||||
|
"services": cs["services"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {**hub_status, "customer_status": customer_status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/images/pull")
|
||||||
|
async def pull_all_netbird_images(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Pull all configured NetBird images from Docker Hub.
|
||||||
|
|
||||||
|
Runs in the background — returns immediately. After pulling, re-check
|
||||||
|
customer status via GET /images/check to see which customers need updating.
|
||||||
|
"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
|
||||||
|
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
|
||||||
|
|
||||||
|
# Snapshot image list before background task starts
|
||||||
|
images = [
|
||||||
|
config.netbird_management_image,
|
||||||
|
config.netbird_signal_image,
|
||||||
|
config.netbird_relay_image,
|
||||||
|
config.netbird_dashboard_image,
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _pull_bg() -> None:
|
||||||
|
bg_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
cfg = bg_db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if cfg:
|
||||||
|
await image_service.pull_all_images(cfg)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Background image pull failed")
|
||||||
|
finally:
|
||||||
|
bg_db.close()
|
||||||
|
|
||||||
|
background_tasks.add_task(_pull_bg)
|
||||||
|
return {"message": "Image pull started in background.", "images": images}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/local-update-status")
|
||||||
|
async def customers_local_update_status(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fast local-only check for outdated customer containers.
|
||||||
|
|
||||||
|
Compares running container image IDs against locally stored images.
|
||||||
|
No network call — safe to call on every dashboard load.
|
||||||
|
|
||||||
|
Results are cached for a few seconds since this is triggered on every
|
||||||
|
customer-table render (including every search keystroke) but the
|
||||||
|
underlying data rarely changes.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
if _update_status_cache["data"] is not None and now < _update_status_cache["expires"]:
|
||||||
|
return _update_status_cache["data"]
|
||||||
|
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not config:
|
||||||
|
return []
|
||||||
|
deployments = db.query(Deployment).all()
|
||||||
|
|
||||||
|
async def _check(dep: Deployment) -> dict[str, Any]:
|
||||||
|
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"], "unknown": cs.get("unknown", False)}
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[_check(dep) for dep in deployments])
|
||||||
|
results = list(results)
|
||||||
|
_update_status_cache["data"] = results
|
||||||
|
_update_status_cache["expires"] = now + _UPDATE_STATUS_TTL_SECONDS
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/customers/update-all")
|
||||||
|
async def update_all_customers(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recreate containers for all customers with outdated images — sequential, synchronous.
|
||||||
|
|
||||||
|
Updates customers one at a time so a failing customer does not block others.
|
||||||
|
Images must already be pulled. Data is preserved (bind mounts).
|
||||||
|
Returns detailed per-customer results.
|
||||||
|
"""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
|
||||||
|
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
|
||||||
|
|
||||||
|
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,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not to_update:
|
||||||
|
return {"message": "All customers are already up to date.", "updated": 0, "results": []}
|
||||||
|
|
||||||
|
# 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 = []
|
||||||
|
for entry in to_update:
|
||||||
|
try:
|
||||||
|
res = await image_service.update_customer_containers(
|
||||||
|
entry["instance_dir"], entry["project_name"]
|
||||||
|
)
|
||||||
|
ok = res["success"]
|
||||||
|
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({
|
||||||
|
"customer_name": entry["customer_name"],
|
||||||
|
"customer_id": entry["customer_id"],
|
||||||
|
"success": ok,
|
||||||
|
"error": error,
|
||||||
|
})
|
||||||
|
|
||||||
|
success_count = sum(1 for r in update_results if r["success"])
|
||||||
|
return {
|
||||||
|
"message": f"Updated {success_count} of {len(update_results)} customer(s).",
|
||||||
|
"updated": success_count,
|
||||||
|
"results": update_results,
|
||||||
|
}
|
||||||
|
|||||||
+39
-10
@@ -4,6 +4,7 @@ There is no .env file. Every setting lives in the ``system_config`` table
|
|||||||
(singleton row with id=1) and is editable via the Web UI settings page.
|
(singleton row with id=1) and is editable via the Web UI settings page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -358,13 +359,15 @@ async def trigger_update(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Backup the database, git pull the latest code, and rebuild the container.
|
"""Kick off backup + git pull + container rebuild in the background.
|
||||||
|
|
||||||
|
Returns immediately — the actual work (which can take several minutes,
|
||||||
|
especially the ``--no-cache`` image build) runs in a background thread so
|
||||||
|
it doesn't block this request or any other user's requests while it
|
||||||
|
runs. Progress can be polled via GET /settings/update/status until the
|
||||||
|
container restarts with the new version.
|
||||||
|
|
||||||
The rebuild is fire-and-forget — the app will restart in ~60 seconds.
|
|
||||||
Only admin users may trigger an update.
|
Only admin users may trigger an update.
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ok, message, and backup path.
|
|
||||||
"""
|
"""
|
||||||
if getattr(current_user, "role", "admin") != "admin":
|
if getattr(current_user, "role", "admin") != "admin":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -383,11 +386,37 @@ async def trigger_update(
|
|||||||
detail="git_repo_url is not configured in settings.",
|
detail="git_repo_url is not configured in settings.",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = update_service.trigger_update(config, DATABASE_PATH)
|
current_status = update_service.get_update_status()
|
||||||
if not result.get("ok"):
|
if current_status.get("state") == "running":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail=result.get("message", "Update failed."),
|
detail="An update is already in progress.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Snapshot the only fields trigger_update() needs — avoids passing a
|
||||||
|
# SQLAlchemy instance into a background thread after this request's
|
||||||
|
# session may already be closed.
|
||||||
|
class _ConfigSnapshot:
|
||||||
|
git_repo_url = config.git_repo_url
|
||||||
|
git_branch = config.git_branch
|
||||||
|
git_token = config.git_token
|
||||||
|
|
||||||
|
asyncio.create_task(asyncio.to_thread(update_service.trigger_update, _ConfigSnapshot(), DATABASE_PATH))
|
||||||
logger.info("Update triggered by %s.", current_user.username)
|
logger.info("Update triggered by %s.", current_user.username)
|
||||||
return result
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "Update gestartet. Dies kann mehrere Minuten dauern — Fortschritt via Status sichtbar.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/update/status")
|
||||||
|
async def update_status(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return progress of the currently running (or last) update.
|
||||||
|
|
||||||
|
Note: once the container restarts mid-update, this endpoint stops
|
||||||
|
responding for a few seconds — that itself is a signal the swap is
|
||||||
|
happening. The frontend falls back to polling for the app coming back up.
|
||||||
|
"""
|
||||||
|
return update_service.get_update_status()
|
||||||
|
|||||||
+20
-1
@@ -70,12 +70,31 @@ async def update_user(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update an existing user (email, is_active, role)."""
|
"""Update an existing user (email, is_active, role). Admin only."""
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only admins can update users.",
|
||||||
|
)
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
||||||
|
|
||||||
update_data = payload.model_dump(exclude_none=True)
|
update_data = payload.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
if "role" in update_data:
|
||||||
|
if update_data["role"] not in ("admin", "viewer"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Role must be 'admin' or 'viewer'.",
|
||||||
|
)
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="You cannot change your own role.",
|
||||||
|
)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if hasattr(user, field):
|
if hasattr(user, field):
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
|
|||||||
@@ -27,13 +27,24 @@ async def _run_cmd(cmd: list[str], timeout: int = 120) -> subprocess.CompletedPr
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_client: Optional[docker.DockerClient] = None
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> docker.DockerClient:
|
def _get_client() -> docker.DockerClient:
|
||||||
"""Return a Docker client connected via the Unix socket.
|
"""Return a shared Docker client connected via the Unix socket.
|
||||||
|
|
||||||
|
The client is created once and reused — creating a new client per call
|
||||||
|
(as `docker.from_env()` does) re-negotiates the API version and opens a
|
||||||
|
fresh connection every time, which is wasteful when called once per
|
||||||
|
customer in a loop.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
docker.DockerClient instance.
|
docker.DockerClient instance.
|
||||||
"""
|
"""
|
||||||
return docker.from_env()
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = docker.from_env()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
async def compose_up(
|
async def compose_up(
|
||||||
@@ -212,6 +223,16 @@ def get_container_status(container_prefix: str) -> list[dict[str, Any]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def get_container_status_async(container_prefix: str) -> list[dict[str, Any]]:
|
||||||
|
"""Thread-offloaded wrapper around get_container_status().
|
||||||
|
|
||||||
|
Use this when checking status for multiple customers so the Docker SDK
|
||||||
|
calls run in the thread pool instead of blocking the event loop.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, get_container_status, container_prefix)
|
||||||
|
|
||||||
|
|
||||||
def get_container_logs(container_name: str, tail: int = 200) -> str:
|
def get_container_logs(container_name: str, tail: int = 200) -> str:
|
||||||
"""Retrieve recent logs from a container.
|
"""Retrieve recent logs from a container.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"""NetBird Docker image update service.
|
||||||
|
|
||||||
|
Compares locally pulled images against Docker Hub to detect available updates.
|
||||||
|
Provides pull and per-customer container recreation functions without data loss.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Services that make up a customer's NetBird deployment
|
||||||
|
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:
|
||||||
|
"""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()
|
||||||
|
try:
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
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]:
|
||||||
|
"""Split 'repo/name:tag' into ('repo/name', 'tag'). Defaults tag to 'latest'."""
|
||||||
|
if ":" in image:
|
||||||
|
name, tag = image.rsplit(":", 1)
|
||||||
|
else:
|
||||||
|
name, tag = image, "latest"
|
||||||
|
return name, tag
|
||||||
|
|
||||||
|
|
||||||
|
async def get_hub_digest(image: str) -> str | None:
|
||||||
|
"""Fetch the manifest-list digest from the Docker Registry v2 API.
|
||||||
|
|
||||||
|
Uses anonymous auth against registry-1.docker.io — does NOT pull the image.
|
||||||
|
Returns the Docker-Content-Digest header value (sha256:...) which is identical
|
||||||
|
to the digest stored in local RepoDigests after a pull, enabling correct comparison.
|
||||||
|
"""
|
||||||
|
name, tag = _parse_image_name(image)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
# Step 1: obtain anonymous pull token
|
||||||
|
token_resp = await client.get(
|
||||||
|
"https://auth.docker.io/token",
|
||||||
|
params={"service": "registry.docker.io", "scope": f"repository:{name}:pull"},
|
||||||
|
)
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
logger.warning("Failed to get registry token for %s", image)
|
||||||
|
return None
|
||||||
|
token = token_resp.json().get("token")
|
||||||
|
|
||||||
|
# Step 2: fetch manifest — prefer manifest list (multi-arch) so the digest
|
||||||
|
# matches what `docker pull` stores in RepoDigests.
|
||||||
|
manifest_resp = await client.get(
|
||||||
|
f"https://registry-1.docker.io/v2/{name}/manifests/{tag}",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": (
|
||||||
|
"application/vnd.docker.distribution.manifest.list.v2+json, "
|
||||||
|
"application/vnd.oci.image.index.v1+json, "
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if manifest_resp.status_code != 200:
|
||||||
|
logger.warning("Registry API returned %d for %s", manifest_resp.status_code, image)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# The Docker-Content-Digest header is the canonical digest
|
||||||
|
digest = manifest_resp.headers.get("docker-content-digest")
|
||||||
|
if digest:
|
||||||
|
return digest
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch registry digest for %s: %s", image, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_digest(image: str) -> str | None:
|
||||||
|
"""Get the RepoDigest for a locally pulled image.
|
||||||
|
|
||||||
|
Returns the digest (sha256:...) or None if image not found locally.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "image", "inspect", image, "--format", "{{json .RepoDigests}}"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
digests = json.loads(result.stdout.strip())
|
||||||
|
if not digests:
|
||||||
|
return None
|
||||||
|
# RepoDigests look like "netbirdio/management@sha256:abc..."
|
||||||
|
for d in digests:
|
||||||
|
if "@" in d:
|
||||||
|
return d.split("@", 1)[1]
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to inspect local image %s: %s", image, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_image_id(container_name: str) -> str | None:
|
||||||
|
"""Get the full image ID (sha256:...) of a running or stopped container."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", container_name, "--format", "{{.Image}}"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout.strip() or None
|
||||||
|
except Exception:
|
||||||
|
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:
|
||||||
|
"""Get the full image ID (sha256:...) of a locally stored image."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "image", "inspect", image, "--format", "{{.Id}}"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout.strip() or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_image_status(image: str) -> dict[str, Any]:
|
||||||
|
"""Check whether a configured image has an update available on Docker Hub.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
image: the image name:tag
|
||||||
|
local_digest: digest of locally cached image (or None)
|
||||||
|
hub_digest: latest digest from Docker Hub (or None)
|
||||||
|
update_available: True if hub_digest differs from local_digest
|
||||||
|
"""
|
||||||
|
hub_digest, local_digest = await asyncio.gather(
|
||||||
|
get_hub_digest(image),
|
||||||
|
asyncio.get_event_loop().run_in_executor(None, get_local_digest, image),
|
||||||
|
)
|
||||||
|
|
||||||
|
if hub_digest and local_digest:
|
||||||
|
update_available = hub_digest != local_digest
|
||||||
|
elif hub_digest and not local_digest:
|
||||||
|
# Image not pulled locally yet — needs pull
|
||||||
|
update_available = True
|
||||||
|
else:
|
||||||
|
update_available = False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"image": image,
|
||||||
|
"local_digest": local_digest,
|
||||||
|
"hub_digest": hub_digest,
|
||||||
|
"update_available": update_available,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def check_all_images(config) -> dict[str, Any]:
|
||||||
|
"""Check all 4 configured NetBird images for available updates.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
images: dict mapping image name -> status dict
|
||||||
|
any_update_available: bool
|
||||||
|
"""
|
||||||
|
images = [
|
||||||
|
config.netbird_management_image,
|
||||||
|
config.netbird_signal_image,
|
||||||
|
config.netbird_relay_image,
|
||||||
|
config.netbird_dashboard_image,
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(*[check_image_status(img) for img in images])
|
||||||
|
by_image = {r["image"]: r for r in results}
|
||||||
|
any_update = any(r["update_available"] for r in results)
|
||||||
|
return {"images": by_image, "any_update_available": any_update}
|
||||||
|
|
||||||
|
|
||||||
|
async def pull_image(image: str) -> dict[str, Any]:
|
||||||
|
"""Pull a Docker image. Returns success/error dict."""
|
||||||
|
logger.info("Pulling image: %s", image)
|
||||||
|
result = await _run_cmd(["docker", "pull", image], timeout=600)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Failed to pull %s: %s", image, result.stderr)
|
||||||
|
return {"image": image, "success": False, "error": result.stderr[:500]}
|
||||||
|
return {"image": image, "success": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def pull_all_images(config) -> dict[str, Any]:
|
||||||
|
"""Pull all 4 configured NetBird images. Returns results per image."""
|
||||||
|
images = [
|
||||||
|
config.netbird_management_image,
|
||||||
|
config.netbird_signal_image,
|
||||||
|
config.netbird_relay_image,
|
||||||
|
config.netbird_dashboard_image,
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(*[pull_image(img) for img in images])
|
||||||
|
return {
|
||||||
|
"results": {r["image"]: r for r in results},
|
||||||
|
"all_success": all(r["success"] for r in results),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_customer_container_image_status_async(container_prefix: str, config) -> dict[str, Any]:
|
||||||
|
"""Async, thread-offloaded version of get_customer_container_image_status().
|
||||||
|
|
||||||
|
Runs the per-service `docker inspect` subprocess calls concurrently in the
|
||||||
|
thread pool instead of sequentially blocking the event loop — use this
|
||||||
|
whenever checking status for multiple customers (e.g. dashboard/search
|
||||||
|
badge refresh, monitoring overview).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
services: dict mapping service name to status info
|
||||||
|
needs_update: True if any service has a different image ID than locally stored
|
||||||
|
"""
|
||||||
|
service_images = {
|
||||||
|
"management": config.netbird_management_image,
|
||||||
|
"signal": config.netbird_signal_image,
|
||||||
|
"relay": config.netbird_relay_image,
|
||||||
|
"dashboard": config.netbird_dashboard_image,
|
||||||
|
}
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
# 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]]:
|
||||||
|
container_name = f"{container_prefix}-{svc}"
|
||||||
|
container_id, local_id = await asyncio.gather(
|
||||||
|
loop.run_in_executor(None, get_container_image_id, container_name),
|
||||||
|
loop.run_in_executor(None, get_local_image_id, image),
|
||||||
|
)
|
||||||
|
if container_id and local_id:
|
||||||
|
up_to_date = container_id == local_id
|
||||||
|
else:
|
||||||
|
up_to_date = None # container not running or image not pulled
|
||||||
|
return svc, {"container": container_name, "image": image, "up_to_date": up_to_date}
|
||||||
|
|
||||||
|
pairs = await asyncio.gather(*[_check(svc, image) for svc, image in service_images.items()])
|
||||||
|
services = dict(pairs)
|
||||||
|
needs_update = any(s["up_to_date"] is False for s in services.values())
|
||||||
|
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]:
|
||||||
|
"""Check which service containers are running outdated local images.
|
||||||
|
|
||||||
|
Compares each running container's image ID against the locally stored image ID
|
||||||
|
for the configured image tag. This is a local check — no network call.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
services: dict mapping service name to status info
|
||||||
|
needs_update: True if any service has a different image ID than locally stored
|
||||||
|
"""
|
||||||
|
service_images = {
|
||||||
|
"management": config.netbird_management_image,
|
||||||
|
"signal": config.netbird_signal_image,
|
||||||
|
"relay": config.netbird_relay_image,
|
||||||
|
"dashboard": config.netbird_dashboard_image,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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] = {}
|
||||||
|
for svc, image in service_images.items():
|
||||||
|
container_name = f"{container_prefix}-{svc}"
|
||||||
|
container_id = get_container_image_id(container_name)
|
||||||
|
local_id = get_local_image_id(image)
|
||||||
|
if container_id and local_id:
|
||||||
|
up_to_date = container_id == local_id
|
||||||
|
else:
|
||||||
|
up_to_date = None # container not running or image not pulled
|
||||||
|
services[svc] = {
|
||||||
|
"container": container_name,
|
||||||
|
"image": image,
|
||||||
|
"up_to_date": up_to_date,
|
||||||
|
}
|
||||||
|
needs_update = any(s["up_to_date"] is False for s in services.values())
|
||||||
|
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]:
|
||||||
|
"""Recreate customer containers to pick up newly pulled images.
|
||||||
|
|
||||||
|
Runs `docker compose up -d` in the customer's instance directory.
|
||||||
|
Images must already be pulled. Bind-mounted data is preserved — no data loss.
|
||||||
|
"""
|
||||||
|
compose_file = os.path.join(instance_dir, "docker-compose.yml")
|
||||||
|
if not os.path.isfile(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 = [
|
||||||
|
"docker", "compose",
|
||||||
|
"-f", compose_file,
|
||||||
|
"-p", project_name,
|
||||||
|
"up", "-d", "--remove-orphans",
|
||||||
|
]
|
||||||
|
logger.info("Updating containers for %s", project_name)
|
||||||
|
result = await _run_cmd(cmd, timeout=300)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {"success": False, "error": result.stderr[:1000]}
|
||||||
|
return {"success": True}
|
||||||
@@ -264,10 +264,12 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
_log_action(db, customer_id, "deploy", "info",
|
_log_action(db, customer_id, "deploy", "info",
|
||||||
"Auto-setup failed — admin must complete setup manually.")
|
"Auto-setup failed — admin must complete setup manually.")
|
||||||
|
|
||||||
# Step 9: Create NPM proxy host + stream (production only)
|
# Step 9: Create NPM proxy host (production only).
|
||||||
npm_proxy_id = None
|
# If an existing deployment already has an NPM proxy, reuse it — this happens
|
||||||
npm_stream_id = None
|
# when keep_data=True was passed and undeploy_customer was NOT called beforehand.
|
||||||
if not local_mode:
|
npm_proxy_id = existing_deployment.npm_proxy_id if existing_deployment else None
|
||||||
|
npm_stream_id = existing_deployment.npm_stream_id if existing_deployment else None
|
||||||
|
if not local_mode and not npm_proxy_id:
|
||||||
forward_host = npm_service._get_forward_host()
|
forward_host = npm_service._get_forward_host()
|
||||||
npm_result = await npm_service.create_proxy_host(
|
npm_result = await npm_service.create_proxy_host(
|
||||||
api_url=config.npm_api_url,
|
api_url=config.npm_api_url,
|
||||||
@@ -294,27 +296,6 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
f"(SSL: {'OK' if ssl_ok else 'FAILED — check DNS and port 80 accessibility'})",
|
f"(SSL: {'OK' if ssl_ok else 'FAILED — check DNS and port 80 accessibility'})",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create NPM UDP stream for relay STUN port
|
|
||||||
stream_result = await npm_service.create_stream(
|
|
||||||
api_url=config.npm_api_url,
|
|
||||||
npm_email=config.npm_api_email,
|
|
||||||
npm_password=config.npm_api_password,
|
|
||||||
incoming_port=allocated_port,
|
|
||||||
forwarding_host=forward_host,
|
|
||||||
forwarding_port=allocated_port,
|
|
||||||
)
|
|
||||||
npm_stream_id = stream_result.get("stream_id")
|
|
||||||
if stream_result.get("error"):
|
|
||||||
_log_action(
|
|
||||||
db, customer_id, "deploy", "error",
|
|
||||||
f"NPM stream creation failed: {stream_result['error']}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_log_action(
|
|
||||||
db, customer_id, "deploy", "info",
|
|
||||||
f"NPM UDP stream created: port {allocated_port} -> {forward_host}:{allocated_port}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Note: Keep HTTPS configs even if SSL cert creation failed.
|
# Note: Keep HTTPS configs even if SSL cert creation failed.
|
||||||
# SSL can be set up manually in NPM later. Switching to HTTP
|
# SSL can be set up manually in NPM later. Switching to HTTP
|
||||||
# would break the dashboard when the user accesses via HTTPS.
|
# would break the dashboard when the user accesses via HTTPS.
|
||||||
@@ -325,9 +306,14 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
"SSL certificate not created automatically. "
|
"SSL certificate not created automatically. "
|
||||||
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.",
|
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.",
|
||||||
)
|
)
|
||||||
|
elif npm_proxy_id and not local_mode:
|
||||||
|
_log_action(db, customer_id, "deploy", "info",
|
||||||
|
f"Reusing existing NPM proxy (ID {npm_proxy_id}) — data preserved.")
|
||||||
|
|
||||||
# Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment)
|
# Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment).
|
||||||
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip:
|
# Skip if an existing deployment is being kept (DNS record already exists).
|
||||||
|
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip \
|
||||||
|
and not existing_deployment:
|
||||||
try:
|
try:
|
||||||
dns_result = await dns_service.create_dns_record(customer.subdomain, config)
|
dns_result = await dns_service.create_dns_record(customer.subdomain, config)
|
||||||
if dns_result["ok"]:
|
if dns_result["ok"]:
|
||||||
@@ -443,17 +429,6 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log_action(db, customer_id, "undeploy", "error", f"NPM removal error: {exc}")
|
_log_action(db, customer_id, "undeploy", "error", f"NPM removal error: {exc}")
|
||||||
|
|
||||||
# Remove NPM stream
|
|
||||||
if deployment.npm_stream_id and config.npm_api_email:
|
|
||||||
try:
|
|
||||||
await npm_service.delete_stream(
|
|
||||||
config.npm_api_url, config.npm_api_email, config.npm_api_password,
|
|
||||||
deployment.npm_stream_id,
|
|
||||||
)
|
|
||||||
_log_action(db, customer_id, "undeploy", "info", "NPM stream removed.")
|
|
||||||
except Exception as exc:
|
|
||||||
_log_action(db, customer_id, "undeploy", "error", f"NPM stream removal error: {exc}")
|
|
||||||
|
|
||||||
# Remove Windows DNS A-record (non-fatal)
|
# Remove Windows DNS A-record (non-fatal)
|
||||||
if config and config.dns_enabled and config.dns_server and config.dns_zone:
|
if config and config.dns_enabled and config.dns_server and config.dns_zone:
|
||||||
try:
|
try:
|
||||||
@@ -484,17 +459,16 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Stop containers for a customer."""
|
"""Stop containers for a customer."""
|
||||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||||
|
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
config = get_system_config(db)
|
config = get_system_config(db)
|
||||||
if not deployment or not config:
|
if not deployment or not config or not customer:
|
||||||
return {"success": False, "error": "Deployment or config not found."}
|
return {"success": False, "error": "Deployment, customer or config not found."}
|
||||||
|
|
||||||
instance_dir = os.path.join(config.data_dir, customer.subdomain)
|
instance_dir = os.path.join(config.data_dir, customer.subdomain)
|
||||||
ok = await docker_service.compose_stop(instance_dir, deployment.container_prefix)
|
ok = await docker_service.compose_stop(instance_dir, deployment.container_prefix)
|
||||||
if ok:
|
if ok:
|
||||||
deployment.deployment_status = "stopped"
|
deployment.deployment_status = "stopped"
|
||||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
customer.status = "inactive"
|
||||||
if customer:
|
|
||||||
customer.status = "inactive"
|
|
||||||
db.commit()
|
db.commit()
|
||||||
_log_action(db, customer_id, "stop", "success", "Containers stopped.")
|
_log_action(db, customer_id, "stop", "success", "Containers stopped.")
|
||||||
else:
|
else:
|
||||||
@@ -505,17 +479,16 @@ async def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
async def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
async def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Start containers for a customer."""
|
"""Start containers for a customer."""
|
||||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||||
|
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
config = get_system_config(db)
|
config = get_system_config(db)
|
||||||
if not deployment or not config:
|
if not deployment or not config or not customer:
|
||||||
return {"success": False, "error": "Deployment or config not found."}
|
return {"success": False, "error": "Deployment, customer or config not found."}
|
||||||
|
|
||||||
instance_dir = os.path.join(config.data_dir, customer.subdomain)
|
instance_dir = os.path.join(config.data_dir, customer.subdomain)
|
||||||
ok = await docker_service.compose_start(instance_dir, deployment.container_prefix)
|
ok = await docker_service.compose_start(instance_dir, deployment.container_prefix)
|
||||||
if ok:
|
if ok:
|
||||||
deployment.deployment_status = "running"
|
deployment.deployment_status = "running"
|
||||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
customer.status = "active"
|
||||||
if customer:
|
|
||||||
customer.status = "active"
|
|
||||||
db.commit()
|
db.commit()
|
||||||
_log_action(db, customer_id, "start", "success", "Containers started.")
|
_log_action(db, customer_id, "start", "success", "Containers started.")
|
||||||
else:
|
else:
|
||||||
@@ -526,17 +499,16 @@ async def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
async def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
async def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Restart containers for a customer."""
|
"""Restart containers for a customer."""
|
||||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||||
|
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
config = get_system_config(db)
|
config = get_system_config(db)
|
||||||
if not deployment or not config:
|
if not deployment or not config or not customer:
|
||||||
return {"success": False, "error": "Deployment or config not found."}
|
return {"success": False, "error": "Deployment, customer or config not found."}
|
||||||
|
|
||||||
instance_dir = os.path.join(config.data_dir, customer.subdomain)
|
instance_dir = os.path.join(config.data_dir, customer.subdomain)
|
||||||
ok = await docker_service.compose_restart(instance_dir, deployment.container_prefix)
|
ok = await docker_service.compose_restart(instance_dir, deployment.container_prefix)
|
||||||
if ok:
|
if ok:
|
||||||
deployment.deployment_status = "running"
|
deployment.deployment_status = "running"
|
||||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
customer.status = "active"
|
||||||
if customer:
|
|
||||||
customer.status = "active"
|
|
||||||
db.commit()
|
db.commit()
|
||||||
_log_action(db, customer_id, "restart", "success", "Containers restarted.")
|
_log_action(db, customer_id, "restart", "success", "Containers restarted.")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+94
-16
@@ -12,9 +12,12 @@ Let's Encrypt SSL certificates.
|
|||||||
Also manages NPM streams for STUN/TURN relay UDP ports.
|
Also manages NPM streams for STUN/TURN relay UDP ports.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -24,6 +27,14 @@ logger = logging.getLogger(__name__)
|
|||||||
# Timeout for NPM API calls (seconds)
|
# Timeout for NPM API calls (seconds)
|
||||||
NPM_TIMEOUT = 30
|
NPM_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Cached JWTs, keyed by (api_url, email). NPM issues a token that stays valid
|
||||||
|
# for a while (per its 'exp' claim), so re-logging in on every single API
|
||||||
|
# call — as this module used to do — adds a full extra round-trip per action
|
||||||
|
# for no reason.
|
||||||
|
_token_cache: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
_TOKEN_SAFETY_MARGIN = 60 # refresh this many seconds before actual expiry
|
||||||
|
_DEFAULT_TOKEN_TTL = 3600 # fallback if the 'exp' claim can't be parsed
|
||||||
|
|
||||||
|
|
||||||
def _get_forward_host() -> str:
|
def _get_forward_host() -> str:
|
||||||
"""Get the host machine's real IP address for NPM forwarding.
|
"""Get the host machine's real IP address for NPM forwarding.
|
||||||
@@ -90,6 +101,61 @@ async def _npm_login(client: httpx.AsyncClient, api_url: str, email: str, passwo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt_exp(token: str) -> float | None:
|
||||||
|
"""Best-effort decode of a JWT's 'exp' claim, without verifying the signature.
|
||||||
|
|
||||||
|
We only use this to size our own cache TTL — NPM itself still enforces
|
||||||
|
the real expiry server-side, so an inaccurate read here is harmless.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload_b64 = token.split(".")[1]
|
||||||
|
padding = "=" * (-len(payload_b64) % 4)
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
||||||
|
return payload.get("exp")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_token(
|
||||||
|
client: httpx.AsyncClient, api_url: str, email: str, password: str, force_refresh: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Return a cached NPM JWT if still valid, otherwise log in and cache it."""
|
||||||
|
cache_key = (api_url, email)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = _token_cache.get(cache_key)
|
||||||
|
if cached and time.time() < cached["expires_at"]:
|
||||||
|
return cached["token"]
|
||||||
|
|
||||||
|
token = await _npm_login(client, api_url, email, password)
|
||||||
|
exp = _decode_jwt_exp(token)
|
||||||
|
expires_at = (exp - _TOKEN_SAFETY_MARGIN) if exp else (time.time() + _DEFAULT_TOKEN_TTL)
|
||||||
|
_token_cache[cache_key] = {"token": token, "expires_at": expires_at}
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_with_reauth(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
method: str,
|
||||||
|
api_url: str,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
path: str,
|
||||||
|
headers: dict,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> tuple[httpx.Response, dict]:
|
||||||
|
"""Perform a request; if the cached token was rejected, refresh and retry once.
|
||||||
|
|
||||||
|
Returns the response and the (possibly updated) headers dict, so callers
|
||||||
|
can reuse the fresh token for any further requests in the same session.
|
||||||
|
"""
|
||||||
|
resp = await client.request(method, f"{api_url}{path}", headers=headers, **kwargs)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
token = await _get_token(client, api_url, email, password, force_refresh=True)
|
||||||
|
headers = {**headers, "Authorization": f"Bearer {token}"}
|
||||||
|
resp = await client.request(method, f"{api_url}{path}", headers=headers, **kwargs)
|
||||||
|
return resp, headers
|
||||||
|
|
||||||
|
|
||||||
async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]:
|
async def test_npm_connection(api_url: str, email: str, password: str) -> dict[str, Any]:
|
||||||
"""Test connectivity to NPM by logging in and listing proxy hosts.
|
"""Test connectivity to NPM by logging in and listing proxy hosts.
|
||||||
|
|
||||||
@@ -103,9 +169,11 @@ async def test_npm_connection(api_url: str, email: str, password: str) -> dict[s
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, email, password)
|
token = await _get_token(client, api_url, email, password)
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await client.get(f"{api_url}/nginx/proxy-hosts", headers=headers)
|
resp, headers = await _request_with_reauth(
|
||||||
|
client, "GET", api_url, email, password, "/nginx/proxy-hosts", headers
|
||||||
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
count = len(resp.json())
|
count = len(resp.json())
|
||||||
return {"ok": True, "message": f"Connected. Login OK. {count} proxy hosts found."}
|
return {"ok": True, "message": f"Connected. Login OK. {count} proxy hosts found."}
|
||||||
@@ -136,9 +204,11 @@ async def list_certificates(api_url: str, email: str, password: str) -> dict[str
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, email, password)
|
token = await _get_token(client, api_url, email, password)
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await client.get(f"{api_url}/nginx/certificates", headers=headers)
|
resp, headers = await _request_with_reauth(
|
||||||
|
client, "GET", api_url, email, password, "/nginx/certificates", headers
|
||||||
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
result = []
|
result = []
|
||||||
for cert in resp.json():
|
for cert in resp.json():
|
||||||
@@ -263,10 +333,14 @@ async def create_proxy_host(
|
|||||||
"location ^~ /management.ManagementService/ {\n"
|
"location ^~ /management.ManagementService/ {\n"
|
||||||
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
|
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
|
||||||
" grpc_set_header Host $host;\n"
|
" grpc_set_header Host $host;\n"
|
||||||
|
" grpc_read_timeout 3600s;\n"
|
||||||
|
" grpc_send_timeout 3600s;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"location ^~ /signalexchange.SignalExchange/ {\n"
|
"location ^~ /signalexchange.SignalExchange/ {\n"
|
||||||
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
|
f" grpc_pass grpc://{forward_host}:{forward_port};\n"
|
||||||
" grpc_set_header Host $host;\n"
|
" grpc_set_header Host $host;\n"
|
||||||
|
" grpc_read_timeout 3600s;\n"
|
||||||
|
" grpc_send_timeout 3600s;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
),
|
),
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -278,14 +352,15 @@ async def create_proxy_host(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=180) as client: # Long timeout for LE cert
|
async with httpx.AsyncClient(timeout=180) as client: # Long timeout for LE cert
|
||||||
token = await _npm_login(client, api_url, npm_email, npm_password)
|
token = await _get_token(client, api_url, npm_email, npm_password)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = await client.post(
|
resp, headers = await _request_with_reauth(
|
||||||
f"{api_url}/nginx/proxy-hosts", json=payload, headers=headers
|
client, "POST", api_url, npm_email, npm_password,
|
||||||
|
"/nginx/proxy-hosts", headers, json=payload,
|
||||||
)
|
)
|
||||||
if resp.status_code in (200, 201):
|
if resp.status_code in (200, 201):
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -538,14 +613,15 @@ async def create_stream(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, npm_email, npm_password)
|
token = await _get_token(client, api_url, npm_email, npm_password)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp = await client.post(
|
resp, headers = await _request_with_reauth(
|
||||||
f"{api_url}/nginx/streams", json=payload, headers=headers
|
client, "POST", api_url, npm_email, npm_password,
|
||||||
|
"/nginx/streams", headers, json=payload,
|
||||||
)
|
)
|
||||||
if resp.status_code in (200, 201):
|
if resp.status_code in (200, 201):
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -583,10 +659,11 @@ async def delete_stream(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, npm_email, npm_password)
|
token = await _get_token(client, api_url, npm_email, npm_password)
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await client.delete(
|
resp, headers = await _request_with_reauth(
|
||||||
f"{api_url}/nginx/streams/{stream_id}", headers=headers
|
client, "DELETE", api_url, npm_email, npm_password,
|
||||||
|
f"/nginx/streams/{stream_id}", headers,
|
||||||
)
|
)
|
||||||
if resp.status_code in (200, 204):
|
if resp.status_code in (200, 204):
|
||||||
logger.info("Deleted NPM stream %d", stream_id)
|
logger.info("Deleted NPM stream %d", stream_id)
|
||||||
@@ -619,10 +696,11 @@ async def delete_proxy_host(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=NPM_TIMEOUT) as client:
|
||||||
token = await _npm_login(client, api_url, npm_email, npm_password)
|
token = await _get_token(client, api_url, npm_email, npm_password)
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
resp = await client.delete(
|
resp, headers = await _request_with_reauth(
|
||||||
f"{api_url}/nginx/proxy-hosts/{proxy_id}", headers=headers
|
client, "DELETE", api_url, npm_email, npm_password,
|
||||||
|
f"/nginx/proxy-hosts/{proxy_id}", headers,
|
||||||
)
|
)
|
||||||
if resp.status_code in (200, 204):
|
if resp.status_code in (200, 204):
|
||||||
logger.info("Deleted NPM proxy host %d", proxy_id)
|
logger.info("Deleted NPM proxy host %d", proxy_id)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ services:
|
|||||||
- "${WEB_UI_PORT:-8000}:8000"
|
- "${WEB_UI_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data:z
|
- ./data:/app/data:z
|
||||||
|
- ./data/uploads:/app/static/uploads:z
|
||||||
- ./logs:/app/logs:z
|
- ./logs:/app/logs:z
|
||||||
- ./backups:/app/backups:z
|
- ./backups:/app/backups:z
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:z
|
- /var/run/docker.sock:/var/run/docker.sock:z
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -188,3 +208,36 @@ body.i18n-loading #app-page {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(0, 0, 0, 0.02);
|
background: rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Dark mode overrides (Bootstrap 5.3 data-bs-theme="dark")
|
||||||
|
Bootstrap handles most components automatically; only custom elements need
|
||||||
|
explicit overrides here.
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
[data-bs-theme="dark"] .card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .card-header {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .log-entry {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .log-time {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .table th {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .login-container {
|
||||||
|
background: linear-gradient(135deg, #0d0d1a 0%, #0a1020 50%, #071525 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .stat-card {
|
||||||
|
background: var(--bs-card-bg);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- Blue rounded background -->
|
||||||
|
<rect width="32" height="32" rx="7" fill="#2563EB"/>
|
||||||
|
|
||||||
|
<!-- Bird silhouette: top-down view, wings spread, forked tail -->
|
||||||
|
<path fill="white" d="
|
||||||
|
M 16 7
|
||||||
|
C 15 8 14 9.5 14 11
|
||||||
|
C 11 10.5 7 11 4 14
|
||||||
|
C 8 15 12 14.5 14 14.5
|
||||||
|
L 15 22
|
||||||
|
L 13 26
|
||||||
|
L 16 24
|
||||||
|
L 19 26
|
||||||
|
L 17 22
|
||||||
|
L 18 14.5
|
||||||
|
C 20 14.5 24 15 28 14
|
||||||
|
C 25 11 21 10.5 18 11
|
||||||
|
C 18 9.5 17 8 16 7 Z
|
||||||
|
"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
+117
-7
@@ -5,6 +5,14 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NetBird MSP Appliance</title>
|
<title>NetBird MSP Appliance</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
<script>
|
||||||
|
// Apply dark mode before page renders to prevent flash
|
||||||
|
(function () {
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
if (saved === 'dark') document.documentElement.setAttribute('data-bs-theme', 'dark');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
<link href="/static/css/styles.css" rel="stylesheet">
|
<link href="/static/css/styles.css" rel="stylesheet">
|
||||||
@@ -108,6 +116,10 @@
|
|||||||
<span id="nav-brand-name">NetBird MSP</span>
|
<span id="nav-brand-name">NetBird MSP</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
|
<button class="btn btn-outline-light btn-sm me-2" id="darkmode-toggle" onclick="toggleDarkMode()" title="Toggle dark mode">
|
||||||
|
<i id="darkmode-icon" class="bi bi-moon-fill"></i>
|
||||||
|
</button>
|
||||||
<!-- Language Switcher -->
|
<!-- Language Switcher -->
|
||||||
<div class="dropdown me-2">
|
<div class="dropdown me-2">
|
||||||
<button class="btn btn-outline-light btn-sm dropdown-toggle" id="language-switcher-btn"
|
<button class="btn btn-outline-light btn-sm dropdown-toggle" id="language-switcher-btn"
|
||||||
@@ -242,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>
|
||||||
@@ -622,6 +634,40 @@
|
|||||||
Settings</span></button>
|
Settings</span></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<hr>
|
||||||
|
<h6 data-i18n="settings.pullImagesTitle">Pull Latest Images from Docker Hub</h6>
|
||||||
|
<p class="text-muted small" data-i18n="settings.pullImagesHint">Downloads the latest versions of all configured NetBird images. After pulling, use Monitoring to update customer containers.</p>
|
||||||
|
<button class="btn btn-outline-primary" onclick="pullAllImagesSettings()" id="btn-pull-images-settings">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i><span data-i18n="settings.pullImages">Pull from Docker Hub</span>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
@@ -1140,7 +1186,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Customer Statuses -->
|
<!-- Customer Statuses -->
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-header" data-i18n="monitoring.allCustomerDeployments">All Customer Deployments
|
<div class="card-header" data-i18n="monitoring.allCustomerDeployments">All Customer Deployments
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -1166,6 +1212,27 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- NetBird Container Updates -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-arrow-repeat me-2"></i><span data-i18n="monitoring.imageUpdates">NetBird Container Updates</span></span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="checkImageUpdates()" id="btn-check-updates">
|
||||||
|
<i class="bi bi-search me-1"></i><span data-i18n="monitoring.checkUpdates">Check for Updates</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" onclick="pullAllImages()" id="btn-pull-images">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i><span data-i18n="monitoring.pullImages">Pull Latest Images</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning btn-sm d-none" onclick="updateAllCustomers()" id="btn-update-all">
|
||||||
|
<i class="bi bi-lightning-charge-fill me-1"></i><span data-i18n="monitoring.updateAll">Update All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="image-updates-body">
|
||||||
|
<p class="text-muted mb-0" data-i18n="monitoring.clickCheckUpdates">Click "Check for Updates" to compare local images with Docker Hub.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1227,6 +1294,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Redeploy Confirmation -->
|
||||||
|
<div class="modal fade" id="redeploy-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title" data-i18n="redeployModal.title">Redeploy Customer</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p><span data-i18n="redeployModal.intro">How should</span> <strong id="redeploy-customer-name"></strong> <span data-i18n="redeployModal.intro2">be redeployed?</span></p>
|
||||||
|
<input type="hidden" id="redeploy-customer-id">
|
||||||
|
<div class="row g-3 mt-1">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100 border-success" style="cursor:pointer" onclick="confirmRedeploy(true)" id="redeploy-card-keep">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-success"><i class="bi bi-shield-check me-2"></i><span data-i18n="redeployModal.keepTitle">Keep Data</span></h6>
|
||||||
|
<p class="card-text small" data-i18n="redeployModal.keepDesc">Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-success bg-opacity-10 text-success small" data-i18n="redeployModal.keepNote">
|
||||||
|
Peers stay connected after restart.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100 border-danger" style="cursor:pointer" onclick="confirmRedeploy(false)" id="redeploy-card-fresh">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title text-danger"><i class="bi bi-trash me-2"></i><span data-i18n="redeployModal.freshTitle">Fresh Deploy</span></h6>
|
||||||
|
<p class="card-text small" data-i18n="redeployModal.freshDesc">All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-danger bg-opacity-10 text-danger small" data-i18n="redeployModal.freshNote">
|
||||||
|
All peer data is lost. Cannot be undone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal: Delete Confirmation -->
|
<!-- Modal: Delete Confirmation -->
|
||||||
<div class="modal fade" id="delete-modal" tabindex="-1">
|
<div class="modal fade" id="delete-modal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
+431
-10
@@ -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 };
|
||||||
|
|
||||||
@@ -66,10 +68,35 @@ async function api(method, path, body = null) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dark mode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function toggleDarkMode() {
|
||||||
|
const isDark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.removeAttribute('data-bs-theme');
|
||||||
|
localStorage.setItem('darkMode', 'light');
|
||||||
|
document.getElementById('darkmode-icon').className = 'bi bi-moon-fill';
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
||||||
|
localStorage.setItem('darkMode', 'dark');
|
||||||
|
document.getElementById('darkmode-icon').className = 'bi bi-sun-fill';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDarkmodeIcon() {
|
||||||
|
const icon = document.getElementById('darkmode-icon');
|
||||||
|
if (!icon) return;
|
||||||
|
icon.className = document.documentElement.getAttribute('data-bs-theme') === 'dark'
|
||||||
|
? 'bi bi-sun-fill'
|
||||||
|
: 'bi bi-moon-fill';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Auth
|
// Auth
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
|
syncDarkmodeIcon();
|
||||||
await initI18n();
|
await initI18n();
|
||||||
await loadBranding();
|
await loadBranding();
|
||||||
await loadAzureLoginConfig();
|
await loadAzureLoginConfig();
|
||||||
@@ -433,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)}`;
|
||||||
|
|
||||||
@@ -445,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>`;
|
||||||
@@ -460,11 +512,11 @@ function renderCustomersTable(data) {
|
|||||||
const dashLink = dPort
|
const dashLink = dPort
|
||||||
? `<a href="${esc(dashUrl || 'http://localhost:' + dPort)}" target="_blank" class="text-decoration-none" title="${t('customer.openDashboard')}">:${dPort} <i class="bi bi-box-arrow-up-right"></i></a>`
|
? `<a href="${esc(dashUrl || 'http://localhost:' + dPort)}" target="_blank" class="text-decoration-none" title="${t('customer.openDashboard')}">:${dPort} <i class="bi bi-box-arrow-up-right"></i></a>`
|
||||||
: '-';
|
: '-';
|
||||||
return `<tr>
|
return `<tr data-customer-id="${c.id}">
|
||||||
<td>${c.id}</td>
|
<td>${c.id}</td>
|
||||||
<td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td>
|
<td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td>
|
||||||
<td><code>${esc(c.subdomain)}</code></td>
|
<td><code>${esc(c.subdomain)}</code></td>
|
||||||
<td>${statusBadge(c.status)}</td>
|
<td><span class="customer-status-cell">${statusBadge(c.status)}</span></td>
|
||||||
<td>${dashLink}</td>
|
<td>${dashLink}</td>
|
||||||
<td>${c.max_devices}</td>
|
<td>${c.max_devices}</td>
|
||||||
<td>${formatDate(c.created_at)}</td>
|
<td>${formatDate(c.created_at)}</td>
|
||||||
@@ -492,6 +544,26 @@ function renderCustomersTable(data) {
|
|||||||
paginationHtml += `<li class="page-item ${i === data.page ? 'active' : ''}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
|
paginationHtml += `<li class="page-item ${i === data.page ? 'active' : ''}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
|
||||||
}
|
}
|
||||||
document.getElementById('pagination-controls').innerHTML = paginationHtml;
|
document.getElementById('pagination-controls').innerHTML = paginationHtml;
|
||||||
|
|
||||||
|
// Lazy-load update badges after table renders (best-effort, silent fail)
|
||||||
|
loadCustomerUpdateBadges().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomerUpdateBadges() {
|
||||||
|
const data = await api('GET', '/monitoring/customers/local-update-status');
|
||||||
|
data.forEach(s => {
|
||||||
|
if (!s.needs_update) return;
|
||||||
|
const tr = document.querySelector(`tr[data-customer-id="${s.customer_id}"]`);
|
||||||
|
if (!tr) return;
|
||||||
|
const cell = tr.querySelector('.customer-status-cell');
|
||||||
|
if (cell && !cell.querySelector('.update-badge')) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'badge bg-warning text-dark update-badge ms-1';
|
||||||
|
badge.title = t('monitoring.updateAvailable');
|
||||||
|
badge.innerHTML = '<i class="bi bi-arrow-repeat"></i> Update';
|
||||||
|
cell.appendChild(badge);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToPage(page) {
|
function goToPage(page) {
|
||||||
@@ -622,9 +694,13 @@ async function confirmDeleteCustomer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Customer Actions (start/stop/restart)
|
// Customer Actions (start/stop/restart/deploy)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
async function customerAction(id, action) {
|
async function customerAction(id, action, name) {
|
||||||
|
if (action === 'deploy') {
|
||||||
|
showRedeployModal(id, name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api('POST', `/customers/${id}/${action}`);
|
await api('POST', `/customers/${id}/${action}`);
|
||||||
if (currentPage === 'dashboard') loadCustomers();
|
if (currentPage === 'dashboard') loadCustomers();
|
||||||
@@ -634,6 +710,29 @@ async function customerAction(id, action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showRedeployModal(id, name) {
|
||||||
|
// Prefer passed name, fallback to dashboard table row, then ID
|
||||||
|
if (!name) {
|
||||||
|
const row = document.querySelector(`tr[data-customer-id="${id}"]`);
|
||||||
|
name = row ? row.querySelector('td')?.textContent?.trim() : `#${id}`;
|
||||||
|
}
|
||||||
|
document.getElementById('redeploy-customer-id').value = id;
|
||||||
|
document.getElementById('redeploy-customer-name').textContent = name;
|
||||||
|
new bootstrap.Modal(document.getElementById('redeploy-modal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRedeploy(keepData) {
|
||||||
|
const id = document.getElementById('redeploy-customer-id').value;
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('redeploy-modal'))?.hide();
|
||||||
|
try {
|
||||||
|
await api('POST', `/customers/${id}/deploy?keep_data=${keepData}`);
|
||||||
|
if (currentPage === 'dashboard') loadCustomers();
|
||||||
|
if (currentCustomerId == id) viewCustomer(id);
|
||||||
|
} catch (err) {
|
||||||
|
alert(t('errors.actionFailed', { action: 'deploy', error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Customer Detail
|
// Customer Detail
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -717,8 +816,13 @@ async function viewCustomer(id) {
|
|||||||
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
|
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
|
||||||
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
|
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
|
||||||
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
|
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
|
||||||
<button class="btn btn-outline-primary btn-sm" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
|
<button class="btn btn-outline-primary btn-sm me-1" data-customer-name="${esc(data.name)}" onclick="customerAction(${id},'deploy',this.dataset.customerName)"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
|
||||||
|
<button class="btn btn-outline-warning btn-sm" id="btn-update-images-detail" onclick="updateCustomerImagesFromDetail(${id})">
|
||||||
|
<span id="update-detail-spinner" class="spinner-border spinner-border-sm d-none me-1"></span>
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>${t('customer.updateImages')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="detail-update-result"></div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('detail-deployment-content').innerHTML = `
|
document.getElementById('detail-deployment-content').innerHTML = `
|
||||||
@@ -836,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 || '';
|
||||||
@@ -954,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');
|
||||||
@@ -1297,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;
|
||||||
@@ -1313,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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1343,8 +1532,8 @@ async function loadUsers() {
|
|||||||
<td>${u.id}</td>
|
<td>${u.id}</td>
|
||||||
<td><strong>${esc(u.username)}</strong></td>
|
<td><strong>${esc(u.username)}</strong></td>
|
||||||
<td>${esc(u.email || '-')}</td>
|
<td>${esc(u.email || '-')}</td>
|
||||||
<td><span class="badge bg-info">${esc(u.role || 'admin')}</span></td>
|
<td><span class="badge bg-${u.role === 'admin' ? 'success' : 'secondary'}">${esc(u.role || 'admin')}</span></td>
|
||||||
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
|
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : u.auth_provider === 'ldap' ? 'info' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
|
||||||
<td>${langDisplay}</td>
|
<td>${langDisplay}</td>
|
||||||
<td>${mfaDisplay}</td>
|
<td>${mfaDisplay}</td>
|
||||||
<td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td>
|
<td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td>
|
||||||
@@ -1356,6 +1545,11 @@ async function loadUsers() {
|
|||||||
}
|
}
|
||||||
${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''}
|
${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''}
|
||||||
${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''}
|
${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''}
|
||||||
|
${currentUser && currentUser.role === 'admin' && u.id !== currentUser.id
|
||||||
|
? (u.role === 'admin'
|
||||||
|
? `<button class="btn btn-outline-secondary" title="${t('settings.makeViewer')}" onclick="toggleUserRole(${u.id}, 'admin')"><i class="bi bi-person-dash"></i></button>`
|
||||||
|
: `<button class="btn btn-outline-success" title="${t('settings.makeAdmin')}" onclick="toggleUserRole(${u.id}, 'viewer')"><i class="bi bi-person-check"></i></button>`)
|
||||||
|
: ''}
|
||||||
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -1415,6 +1609,16 @@ async function toggleUserActive(id, active) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleUserRole(id, currentRole) {
|
||||||
|
const newRole = currentRole === 'admin' ? 'viewer' : 'admin';
|
||||||
|
try {
|
||||||
|
await api('PUT', `/users/${id}`, { role: newRole });
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsAlert('danger', t('errors.updateFailed', { error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resetUserPassword(id, username) {
|
async function resetUserPassword(id, username) {
|
||||||
if (!confirm(t('messages.confirmResetPassword', { username }))) return;
|
if (!confirm(t('messages.confirmResetPassword', { username }))) return;
|
||||||
try {
|
try {
|
||||||
@@ -1593,6 +1797,223 @@ async function loadAllCustomerStatuses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Image Updates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function checkImageUpdates() {
|
||||||
|
const btn = document.getElementById('btn-check-updates');
|
||||||
|
const body = document.getElementById('image-updates-body');
|
||||||
|
btn.disabled = true;
|
||||||
|
body.innerHTML = `<div class="text-muted"><span class="spinner-border spinner-border-sm me-2"></span>${t('common.loading')}</div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api('GET', '/monitoring/images/check');
|
||||||
|
|
||||||
|
// Image status table
|
||||||
|
const imageRows = Object.values(data.images).map(img => {
|
||||||
|
const badge = img.update_available
|
||||||
|
? `<span class="badge bg-warning text-dark">${t('monitoring.updateAvailable')}</span>`
|
||||||
|
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
|
||||||
|
const shortDigest = d => d ? d.substring(7, 19) + '…' : '-';
|
||||||
|
return `<tr>
|
||||||
|
<td><code class="small">${esc(img.image)}</code></td>
|
||||||
|
<td class="small text-muted">${shortDigest(img.local_digest)}</td>
|
||||||
|
<td class="small text-muted">${shortDigest(img.hub_digest)}</td>
|
||||||
|
<td>${badge}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Customer status table
|
||||||
|
const customerRows = data.customer_status.length === 0
|
||||||
|
? `<tr><td colspan="3" class="text-center text-muted py-3">${t('monitoring.noCustomers')}</td></tr>`
|
||||||
|
: data.customer_status.map(c => {
|
||||||
|
const badge = c.needs_update
|
||||||
|
? `<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>`;
|
||||||
|
const updateBtn = c.needs_update
|
||||||
|
? `<button class="btn btn-sm btn-outline-warning ms-2 btn-update-customer" onclick="updateCustomerImages(${c.customer_id})"
|
||||||
|
title="${t('monitoring.updateCustomer')}"><i class="bi bi-arrow-repeat"></i></button>`
|
||||||
|
: '';
|
||||||
|
return `<tr>
|
||||||
|
<td>${c.customer_id}</td>
|
||||||
|
<td>${esc(c.customer_name)} <code class="small text-muted">${esc(c.subdomain)}</code></td>
|
||||||
|
<td>${badge}${updateBtn}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Show "Update All" button if any customer needs update
|
||||||
|
const updateAllBtn = document.getElementById('btn-update-all');
|
||||||
|
if (data.customer_status.some(c => c.needs_update)) {
|
||||||
|
updateAllBtn.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
updateAllBtn.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<h6 class="mb-2">${t('monitoring.imageStatusTitle')}</h6>
|
||||||
|
<div class="table-responsive mb-4">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>${t('monitoring.thImage')}</th>
|
||||||
|
<th>${t('monitoring.thLocalDigest')}</th>
|
||||||
|
<th>${t('monitoring.thHubDigest')}</th>
|
||||||
|
<th>${t('monitoring.thStatus')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${imageRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<h6 class="mb-2">${t('monitoring.customerImageTitle')}</h6>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>${t('monitoring.thId')}</th>
|
||||||
|
<th>${t('monitoring.thName')}</th>
|
||||||
|
<th>${t('monitoring.thStatus')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${customerRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} catch (err) {
|
||||||
|
body.innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullAllImages() {
|
||||||
|
if (!confirm(t('monitoring.confirmPull'))) return;
|
||||||
|
const btn = document.getElementById('btn-pull-images');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api('POST', '/monitoring/images/pull');
|
||||||
|
showToast(t('monitoring.pullStarted'));
|
||||||
|
// Re-check after a few seconds to let pull finish
|
||||||
|
setTimeout(() => checkImageUpdates(), 5000);
|
||||||
|
} catch (err) {
|
||||||
|
showMonitoringAlert('danger', err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCustomerImagesFromDetail(id) {
|
||||||
|
const btn = document.getElementById('btn-update-images-detail');
|
||||||
|
const spinner = document.getElementById('update-detail-spinner');
|
||||||
|
const resultDiv = document.getElementById('detail-update-result');
|
||||||
|
btn.disabled = true;
|
||||||
|
spinner.classList.remove('d-none');
|
||||||
|
resultDiv.innerHTML = `<div class="alert alert-info py-2 mt-2"><span class="spinner-border spinner-border-sm me-2"></span>${t('customer.updateInProgress')}</div>`;
|
||||||
|
try {
|
||||||
|
const data = await api('POST', `/customers/${id}/update-images`);
|
||||||
|
resultDiv.innerHTML = `<div class="alert alert-success py-2 mt-2"><i class="bi bi-check-circle me-1"></i>${esc(data.message)}</div>`;
|
||||||
|
setTimeout(() => { resultDiv.innerHTML = ''; }, 6000);
|
||||||
|
} catch (err) {
|
||||||
|
resultDiv.innerHTML = `<div class="alert alert-danger py-2 mt-2"><i class="bi bi-exclamation-circle me-1"></i>${esc(err.message)}</div>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
spinner.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCustomerImages(customerId) {
|
||||||
|
// Find the update button for this customer row and show a spinner
|
||||||
|
const btn = document.querySelector(`tr[data-customer-id="${customerId}"] .btn-update-customer`);
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api('POST', `/customers/${customerId}/update-images`);
|
||||||
|
showToast(t('monitoring.updateDone'));
|
||||||
|
setTimeout(() => checkImageUpdates(), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
showMonitoringAlert('danger', err.message);
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAllCustomers() {
|
||||||
|
if (!confirm(t('monitoring.confirmUpdateAll'))) return;
|
||||||
|
const btn = document.getElementById('btn-update-all');
|
||||||
|
const body = document.getElementById('image-updates-body');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${t('monitoring.updating')}`;
|
||||||
|
|
||||||
|
const progressDiv = document.createElement('div');
|
||||||
|
progressDiv.className = 'alert alert-info mt-3';
|
||||||
|
progressDiv.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${t('monitoring.updateAllProgress')}`;
|
||||||
|
body.appendChild(progressDiv);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api('POST', '/monitoring/customers/update-all');
|
||||||
|
progressDiv.remove();
|
||||||
|
|
||||||
|
if (data.results && data.results.length > 0) {
|
||||||
|
const allOk = data.updated === data.results.length;
|
||||||
|
const rows = data.results.map(r => `<tr>
|
||||||
|
<td>${esc(r.customer_name)}</td>
|
||||||
|
<td>${r.success
|
||||||
|
? '<span class="badge bg-success"><i class="bi bi-check-lg"></i> OK</span>'
|
||||||
|
: '<span class="badge bg-danger"><i class="bi bi-x-lg"></i> Error</span>'}</td>
|
||||||
|
<td class="small text-muted">${esc(r.error || '')}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
const resultHtml = `<div class="alert alert-${allOk ? 'success' : 'warning'} mt-3">
|
||||||
|
<strong>${esc(data.message)}</strong>
|
||||||
|
<table class="table table-sm mb-0 mt-2">
|
||||||
|
<thead><tr><th>${t('monitoring.thName')}</th><th>${t('monitoring.thStatus')}</th><th></th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
body.insertAdjacentHTML('beforeend', resultHtml);
|
||||||
|
} else {
|
||||||
|
showToast(data.message);
|
||||||
|
}
|
||||||
|
setTimeout(() => checkImageUpdates(), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
progressDiv.remove();
|
||||||
|
showMonitoringAlert('danger', err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `<i class="bi bi-lightning-charge-fill me-1"></i>${t('monitoring.updateAll')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullAllImagesSettings() {
|
||||||
|
if (!confirm(t('monitoring.confirmPull'))) return;
|
||||||
|
const btn = document.getElementById('btn-pull-images-settings');
|
||||||
|
const statusEl = document.getElementById('pull-images-settings-status');
|
||||||
|
btn.disabled = true;
|
||||||
|
statusEl.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${t('monitoring.pulling')}`;
|
||||||
|
try {
|
||||||
|
await api('POST', '/monitoring/images/pull');
|
||||||
|
statusEl.innerHTML = `<i class="bi bi-check-circle text-success me-1"></i>${t('monitoring.pullStartedShort')}`;
|
||||||
|
setTimeout(() => { statusEl.innerHTML = ''; }, 8000);
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-circle me-1"></i>${esc(err.message)}</span>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMonitoringAlert(type, msg) {
|
||||||
|
const body = document.getElementById('image-updates-body');
|
||||||
|
const existing = body.querySelector('.alert');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `alert alert-${type} mt-2`;
|
||||||
|
div.textContent = msg;
|
||||||
|
body.prepend(div);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,8 @@ function detectLanguage() {
|
|||||||
async function loadLanguage(lang) {
|
async function loadLanguage(lang) {
|
||||||
if (translations[lang]) return;
|
if (translations[lang]) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/static/lang/${lang}.json`);
|
const v = window.STATIC_VERSION ? `?v=${window.STATIC_VERSION}` : '';
|
||||||
|
const resp = await fetch(`/static/lang/${lang}.json${v}`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
translations[lang] = await resp.json();
|
translations[lang] = await resp.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+62
-2
@@ -89,7 +89,9 @@
|
|||||||
"thHealth": "Zustand",
|
"thHealth": "Zustand",
|
||||||
"thImage": "Image",
|
"thImage": "Image",
|
||||||
"lastCheck": "Letzte Prüfung: {time}",
|
"lastCheck": "Letzte Prüfung: {time}",
|
||||||
"openDashboard": "Dashboard öffnen"
|
"openDashboard": "Dashboard öffnen",
|
||||||
|
"updateImages": "Images aktualisieren",
|
||||||
|
"updateInProgress": "Container werden aktualisiert — bitte warten…"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Systemeinstellungen",
|
"title": "Systemeinstellungen",
|
||||||
@@ -152,6 +154,9 @@
|
|||||||
"dashboardImage": "Dashboard Image",
|
"dashboardImage": "Dashboard Image",
|
||||||
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
|
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
|
||||||
"saveImageSettings": "Image-Einstellungen speichern",
|
"saveImageSettings": "Image-Einstellungen speichern",
|
||||||
|
"pullImagesTitle": "Neueste Images von Docker Hub laden",
|
||||||
|
"pullImagesHint": "Lädt die neuesten Versionen aller konfigurierten NetBird Images. Danach können Kunden-Container über das Monitoring aktualisiert werden.",
|
||||||
|
"pullImages": "Von Docker Hub laden",
|
||||||
"brandingTitle": "Branding-Einstellungen",
|
"brandingTitle": "Branding-Einstellungen",
|
||||||
"companyName": "Firmen- / Anwendungsname",
|
"companyName": "Firmen- / Anwendungsname",
|
||||||
"companyNamePlaceholder": "NetBird MSP Appliance",
|
"companyNamePlaceholder": "NetBird MSP Appliance",
|
||||||
@@ -170,6 +175,8 @@
|
|||||||
"saveBranding": "Branding speichern",
|
"saveBranding": "Branding speichern",
|
||||||
"userManagement": "Benutzerverwaltung",
|
"userManagement": "Benutzerverwaltung",
|
||||||
"newUser": "Neuer Benutzer",
|
"newUser": "Neuer Benutzer",
|
||||||
|
"makeAdmin": "Zum Admin befördern",
|
||||||
|
"makeViewer": "Zum Viewer degradieren",
|
||||||
"thId": "ID",
|
"thId": "ID",
|
||||||
"thUsername": "Benutzername",
|
"thUsername": "Benutzername",
|
||||||
"thEmail": "E-Mail",
|
"thEmail": "E-Mail",
|
||||||
@@ -223,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.",
|
||||||
@@ -347,6 +362,17 @@
|
|||||||
"saveAndDeploy": "Speichern & Bereitstellen",
|
"saveAndDeploy": "Speichern & Bereitstellen",
|
||||||
"saveChanges": "Änderungen speichern"
|
"saveChanges": "Änderungen speichern"
|
||||||
},
|
},
|
||||||
|
"redeployModal": {
|
||||||
|
"title": "Kunde neu bereitstellen",
|
||||||
|
"intro": "Wie soll",
|
||||||
|
"intro2": "neu bereitgestellt werden?",
|
||||||
|
"keepTitle": "Daten behalten",
|
||||||
|
"keepDesc": "Container werden gestoppt und neu gestartet. Die NetBird-Datenbank, Peer-Konfigurationen und Verschlüsselungsschlüssel bleiben erhalten. Verwenden Sie dies nach einer Konfigurationsänderung oder einem Image-Update.",
|
||||||
|
"keepNote": "Peers bleiben nach dem Neustart verbunden.",
|
||||||
|
"freshTitle": "Neu aufsetzen",
|
||||||
|
"freshDesc": "Alle bestehenden Daten werden gelöscht — Container, Volumes, Konfigurationsdateien und die NetBird-Datenbank. Eine komplett neue Instanz wird erstellt. Alle Peers müssen sich neu registrieren.",
|
||||||
|
"freshNote": "Alle Peer-Daten gehen verloren. Kann nicht rückgängig gemacht werden."
|
||||||
|
},
|
||||||
"deleteModal": {
|
"deleteModal": {
|
||||||
"title": "Löschen bestätigen",
|
"title": "Löschen bestätigen",
|
||||||
"confirmText": "Möchten Sie den Kunden wirklich löschen:",
|
"confirmText": "Möchten Sie den Kunden wirklich löschen:",
|
||||||
@@ -371,6 +397,40 @@
|
|||||||
"thDashboard": "Dashboard",
|
"thDashboard": "Dashboard",
|
||||||
"thRelayPort": "Relay-Port",
|
"thRelayPort": "Relay-Port",
|
||||||
"thContainers": "Container",
|
"thContainers": "Container",
|
||||||
"noCustomers": "Keine Kunden."
|
"noCustomers": "Keine Kunden.",
|
||||||
|
"imageUpdates": "NetBird Container Updates",
|
||||||
|
"checkUpdates": "Auf Updates prüfen",
|
||||||
|
"pullImages": "Neueste Images laden",
|
||||||
|
"updateAll": "Alle aktualisieren",
|
||||||
|
"clickCheckUpdates": "Klicken Sie auf \"Auf Updates prüfen\" um lokale Images mit Docker Hub zu vergleichen.",
|
||||||
|
"updateAvailable": "Update verfügbar",
|
||||||
|
"upToDate": "Aktuell",
|
||||||
|
"needsUpdate": "Update erforderlich",
|
||||||
|
"updateCustomer": "Diesen Kunden aktualisieren",
|
||||||
|
"imageStatusTitle": "Image-Status (vs. Docker Hub)",
|
||||||
|
"customerImageTitle": "Kunden-Container Status",
|
||||||
|
"thImage": "Image",
|
||||||
|
"thLocalDigest": "Lokaler Digest",
|
||||||
|
"thHubDigest": "Hub Digest",
|
||||||
|
"confirmPull": "Neueste NetBird Images von Docker Hub laden? Dies kann einige Minuten dauern.",
|
||||||
|
"pullStarted": "Image-Download im Hintergrund gestartet. Prüfung in 5 Sekunden…",
|
||||||
|
"confirmUpdateAll": "Container aller Kunden mit veralteten Images neu erstellen? Laufende Dienste werden kurz neu gestartet.",
|
||||||
|
"updateAllStarted": "Aktualisierung im Hintergrund gestartet.",
|
||||||
|
"updateDone": "Kunden-Container aktualisiert.",
|
||||||
|
"updating": "Wird aktualisiert…",
|
||||||
|
"updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…",
|
||||||
|
"pulling": "Wird geladen…",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+62
-2
@@ -89,7 +89,9 @@
|
|||||||
"thHealth": "Health",
|
"thHealth": "Health",
|
||||||
"thImage": "Image",
|
"thImage": "Image",
|
||||||
"lastCheck": "Last check: {time}",
|
"lastCheck": "Last check: {time}",
|
||||||
"openDashboard": "Open Dashboard"
|
"openDashboard": "Open Dashboard",
|
||||||
|
"updateImages": "Update Images",
|
||||||
|
"updateInProgress": "Updating containers — please wait…"
|
||||||
},
|
},
|
||||||
"customerModal": {
|
"customerModal": {
|
||||||
"newCustomer": "New Customer",
|
"newCustomer": "New Customer",
|
||||||
@@ -105,6 +107,17 @@
|
|||||||
"saveAndDeploy": "Save & Deploy",
|
"saveAndDeploy": "Save & Deploy",
|
||||||
"saveChanges": "Save Changes"
|
"saveChanges": "Save Changes"
|
||||||
},
|
},
|
||||||
|
"redeployModal": {
|
||||||
|
"title": "Redeploy Customer",
|
||||||
|
"intro": "How should",
|
||||||
|
"intro2": "be redeployed?",
|
||||||
|
"keepTitle": "Keep Data",
|
||||||
|
"keepDesc": "Containers are stopped and restarted. The NetBird database, peer configurations, and encryption keys are preserved. Use this after a config change or image update.",
|
||||||
|
"keepNote": "Peers stay connected after restart.",
|
||||||
|
"freshTitle": "Fresh Deploy",
|
||||||
|
"freshDesc": "All existing data is deleted — containers, volumes, config files, and the NetBird database. A completely new instance is created. All peers must re-enroll.",
|
||||||
|
"freshNote": "All peer data is lost. Cannot be undone."
|
||||||
|
},
|
||||||
"deleteModal": {
|
"deleteModal": {
|
||||||
"title": "Confirm Deletion",
|
"title": "Confirm Deletion",
|
||||||
"confirmText": "Are you sure you want to delete customer",
|
"confirmText": "Are you sure you want to delete customer",
|
||||||
@@ -173,6 +186,9 @@
|
|||||||
"dashboardImage": "Dashboard Image",
|
"dashboardImage": "Dashboard Image",
|
||||||
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
|
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
|
||||||
"saveImageSettings": "Save Image Settings",
|
"saveImageSettings": "Save Image Settings",
|
||||||
|
"pullImagesTitle": "Pull Latest Images from Docker Hub",
|
||||||
|
"pullImagesHint": "Downloads the latest versions of all configured NetBird images. After pulling, use Monitoring to update customer containers.",
|
||||||
|
"pullImages": "Pull from Docker Hub",
|
||||||
"brandingTitle": "Branding Settings",
|
"brandingTitle": "Branding Settings",
|
||||||
"companyName": "Company / Application Name",
|
"companyName": "Company / Application Name",
|
||||||
"companyNamePlaceholder": "NetBird MSP Appliance",
|
"companyNamePlaceholder": "NetBird MSP Appliance",
|
||||||
@@ -191,6 +207,8 @@
|
|||||||
"saveBranding": "Save Branding",
|
"saveBranding": "Save Branding",
|
||||||
"userManagement": "User Management",
|
"userManagement": "User Management",
|
||||||
"newUser": "New User",
|
"newUser": "New User",
|
||||||
|
"makeAdmin": "Promote to admin",
|
||||||
|
"makeViewer": "Demote to viewer",
|
||||||
"thId": "ID",
|
"thId": "ID",
|
||||||
"thUsername": "Username",
|
"thUsername": "Username",
|
||||||
"thEmail": "Email",
|
"thEmail": "Email",
|
||||||
@@ -244,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.",
|
||||||
@@ -278,7 +304,41 @@
|
|||||||
"thDashboard": "Dashboard",
|
"thDashboard": "Dashboard",
|
||||||
"thRelayPort": "Relay Port",
|
"thRelayPort": "Relay Port",
|
||||||
"thContainers": "Containers",
|
"thContainers": "Containers",
|
||||||
"noCustomers": "No customers."
|
"noCustomers": "No customers.",
|
||||||
|
"imageUpdates": "NetBird Container Updates",
|
||||||
|
"checkUpdates": "Check for Updates",
|
||||||
|
"pullImages": "Pull Latest Images",
|
||||||
|
"updateAll": "Update All",
|
||||||
|
"clickCheckUpdates": "Click \"Check for Updates\" to compare local images with Docker Hub.",
|
||||||
|
"updateAvailable": "Update available",
|
||||||
|
"upToDate": "Up to date",
|
||||||
|
"needsUpdate": "Needs update",
|
||||||
|
"updateCustomer": "Update this customer",
|
||||||
|
"imageStatusTitle": "Image Status (vs. Docker Hub)",
|
||||||
|
"customerImageTitle": "Customer Container Status",
|
||||||
|
"thImage": "Image",
|
||||||
|
"thLocalDigest": "Local Digest",
|
||||||
|
"thHubDigest": "Hub Digest",
|
||||||
|
"confirmPull": "Pull the latest NetBird images from Docker Hub? This may take a few minutes.",
|
||||||
|
"pullStarted": "Image pull started in background. Re-checking in 5 seconds…",
|
||||||
|
"confirmUpdateAll": "Recreate containers for all customers that have outdated images? Running services will briefly restart.",
|
||||||
|
"updateAllStarted": "Update started in background.",
|
||||||
|
"updateDone": "Customer containers updated.",
|
||||||
|
"updating": "Updating…",
|
||||||
|
"updateAllProgress": "Updating customer containers one by one — please wait…",
|
||||||
|
"pulling": "Pulling…",
|
||||||
|
"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