Compare commits
7 Commits
alpha-1.24
...
beta-1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b39a502257 | |||
| dee07d7b8e | |||
| 351caec893 | |||
| dd04408dc2 | |||
| 6373722c2b | |||
| 27c8e4889c | |||
| 2713e67259 |
207
README.md
207
README.md
@@ -38,11 +38,20 @@ 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
|
||||||
|
|
||||||
|
### 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 +64,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 update check with configurable release branch
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -178,6 +192,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 +292,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 +349,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 +392,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 +420,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 +453,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 +482,37 @@ 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 all customers
|
||||||
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
|
|
||||||
POST /api/users # Create user
|
|
||||||
POST /api/users/{id}/reset-mfa # Reset user's MFA
|
|
||||||
|
|
||||||
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
|
||||||
@@ -488,11 +592,28 @@ The database migrations run automatically on startup.
|
|||||||
|
|
||||||
### 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 +667,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 with [Claude Code](https://claude.ai/claude-code) (Anthropic Claude Sonnet 4.6) — from architecture and backend logic to frontend UI and deployment scripts.
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
|
|||||||
@@ -196,16 +196,37 @@ async def pull_all_netbird_images(
|
|||||||
return {"message": "Image pull started in background.", "images": images}
|
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.
|
||||||
|
"""
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not config:
|
||||||
|
return []
|
||||||
|
deployments = db.query(Deployment).all()
|
||||||
|
results = []
|
||||||
|
for dep in deployments:
|
||||||
|
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
|
||||||
|
results.append({"customer_id": dep.customer_id, "needs_update": cs["needs_update"]})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@router.post("/customers/update-all")
|
@router.post("/customers/update-all")
|
||||||
async def update_all_customers(
|
async def update_all_customers(
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Recreate containers for all customers that have outdated images.
|
"""Recreate containers for all customers with outdated images — sequential, synchronous.
|
||||||
|
|
||||||
Only customers where at least one container runs an outdated image are updated.
|
Updates customers one at a time so a failing customer does not block others.
|
||||||
Images must already be pulled. Data is preserved (bind mounts).
|
Images must already be pulled. Data is preserved (bind mounts).
|
||||||
|
Returns detailed per-customer results.
|
||||||
"""
|
"""
|
||||||
if current_user.role != "admin":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only.")
|
||||||
@@ -214,38 +235,40 @@ async def update_all_customers(
|
|||||||
if not config:
|
if not config:
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="System not configured.")
|
||||||
|
|
||||||
# Collect customers that need updating
|
|
||||||
deployments = db.query(Deployment).all()
|
deployments = db.query(Deployment).all()
|
||||||
to_update = []
|
to_update = []
|
||||||
for dep in deployments:
|
for dep in deployments:
|
||||||
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
|
cs = image_service.get_customer_container_image_status(dep.container_prefix, config)
|
||||||
if cs["needs_update"]:
|
if cs["needs_update"]:
|
||||||
customer = dep.customer
|
customer = dep.customer
|
||||||
instance_dir = str(dep.container_prefix).replace(
|
|
||||||
"netbird-", "", 1
|
|
||||||
) # subdomain
|
|
||||||
to_update.append({
|
to_update.append({
|
||||||
"instance_dir": f"{config.data_dir}/{customer.subdomain}",
|
"instance_dir": f"{config.data_dir}/{customer.subdomain}",
|
||||||
"project_name": dep.container_prefix,
|
"project_name": dep.container_prefix,
|
||||||
"customer_name": customer.name,
|
"customer_name": customer.name,
|
||||||
|
"customer_id": customer.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
if not to_update:
|
if not to_update:
|
||||||
return {"message": "All customers are already up to date.", "updated": 0}
|
return {"message": "All customers are already up to date.", "updated": 0, "results": []}
|
||||||
|
|
||||||
async def _update_all_bg() -> None:
|
# Update customers sequentially — one at a time
|
||||||
for entry in to_update:
|
update_results = []
|
||||||
try:
|
for entry in to_update:
|
||||||
await image_service.update_customer_containers(
|
res = await image_service.update_customer_containers(
|
||||||
entry["instance_dir"], entry["project_name"]
|
entry["instance_dir"], entry["project_name"]
|
||||||
)
|
)
|
||||||
logger.info("Updated containers for %s", entry["project_name"])
|
ok = res["success"]
|
||||||
except Exception:
|
logger.info("Updated %s: %s", entry["project_name"], "OK" if ok else res.get("error"))
|
||||||
logger.exception("Failed to update %s", entry["project_name"])
|
update_results.append({
|
||||||
|
"customer_name": entry["customer_name"],
|
||||||
|
"customer_id": entry["customer_id"],
|
||||||
|
"success": ok,
|
||||||
|
"error": res.get("error"),
|
||||||
|
})
|
||||||
|
|
||||||
background_tasks.add_task(_update_all_bg)
|
success_count = sum(1 for r in update_results if r["success"])
|
||||||
names = [e["customer_name"] for e in to_update]
|
|
||||||
return {
|
return {
|
||||||
"message": f"Updating {len(to_update)} customer(s) in background.",
|
"message": f"Updated {success_count} of {len(update_results)} customer(s).",
|
||||||
"customers": names,
|
"updated": success_count,
|
||||||
|
"results": update_results,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,33 +38,49 @@ def _parse_image_name(image: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
async def get_hub_digest(image: str) -> str | None:
|
async def get_hub_digest(image: str) -> str | None:
|
||||||
"""Fetch the current digest from Docker Hub for an image:tag.
|
"""Fetch the manifest-list digest from the Docker Registry v2 API.
|
||||||
|
|
||||||
Uses the Docker Hub REST API — does NOT pull the image.
|
Uses anonymous auth against registry-1.docker.io — does NOT pull the image.
|
||||||
Returns the digest string (sha256:...) or None on failure.
|
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)
|
name, tag = _parse_image_name(image)
|
||||||
url = f"https://hub.docker.com/v2/repositories/{name}/tags/{tag}/"
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
resp = await client.get(url)
|
# Step 1: obtain anonymous pull token
|
||||||
if resp.status_code != 200:
|
token_resp = await client.get(
|
||||||
logger.warning("Docker Hub API returned %d for %s", resp.status_code, image)
|
"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
|
return None
|
||||||
data = resp.json()
|
|
||||||
images = data.get("images", [])
|
|
||||||
# Prefer linux/amd64 digest
|
|
||||||
for img in images:
|
|
||||||
if img.get("os") == "linux" and img.get("architecture") in ("amd64", ""):
|
|
||||||
d = img.get("digest")
|
|
||||||
if d:
|
|
||||||
return d
|
|
||||||
# Fallback: first available digest
|
|
||||||
if images:
|
|
||||||
return images[0].get("digest")
|
|
||||||
return None
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to fetch Docker Hub digest for %s: %s", image, exc)
|
logger.warning("Failed to fetch registry digest for %s: %s", image, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -634,6 +634,13 @@
|
|||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
114
static/js/app.js
114
static/js/app.js
@@ -485,11 +485,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>
|
||||||
@@ -517,6 +517,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) {
|
||||||
@@ -742,8 +762,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" onclick="customerAction(${id},'deploy')"><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 = `
|
||||||
@@ -1667,7 +1692,7 @@ async function checkImageUpdates() {
|
|||||||
? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>`
|
? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>`
|
||||||
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
|
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
|
||||||
const updateBtn = c.needs_update
|
const updateBtn = c.needs_update
|
||||||
? `<button class="btn btn-sm btn-outline-warning ms-2" onclick="updateCustomerImages(${c.customer_id})"
|
? `<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>`
|
title="${t('monitoring.updateCustomer')}"><i class="bi bi-arrow-repeat"></i></button>`
|
||||||
: '';
|
: '';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
@@ -1736,26 +1761,103 @@ async function pullAllImages() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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 {
|
try {
|
||||||
await api('POST', `/customers/${customerId}/update-images`);
|
await api('POST', `/customers/${customerId}/update-images`);
|
||||||
showToast(t('monitoring.updateDone'));
|
showToast(t('monitoring.updateDone'));
|
||||||
setTimeout(() => checkImageUpdates(), 2000);
|
setTimeout(() => checkImageUpdates(), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showMonitoringAlert('danger', err.message);
|
showMonitoringAlert('danger', err.message);
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateAllCustomers() {
|
async function updateAllCustomers() {
|
||||||
if (!confirm(t('monitoring.confirmUpdateAll'))) return;
|
if (!confirm(t('monitoring.confirmUpdateAll'))) return;
|
||||||
const btn = document.getElementById('btn-update-all');
|
const btn = document.getElementById('btn-update-all');
|
||||||
|
const body = document.getElementById('image-updates-body');
|
||||||
btn.disabled = true;
|
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 {
|
try {
|
||||||
const data = await api('POST', '/monitoring/customers/update-all');
|
const data = await api('POST', '/monitoring/customers/update-all');
|
||||||
showToast(data.message || t('monitoring.updateAllStarted'));
|
progressDiv.remove();
|
||||||
setTimeout(() => checkImageUpdates(), 5000);
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
|
progressDiv.remove();
|
||||||
showMonitoringAlert('danger', err.message);
|
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 {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -392,6 +397,10 @@
|
|||||||
"pullStarted": "Image-Download im Hintergrund gestartet. Prüfung in 5 Sekunden…",
|
"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.",
|
"confirmUpdateAll": "Container aller Kunden mit veralteten Images neu erstellen? Laufende Dienste werden kurz neu gestartet.",
|
||||||
"updateAllStarted": "Aktualisierung im Hintergrund gestartet.",
|
"updateAllStarted": "Aktualisierung im Hintergrund gestartet.",
|
||||||
"updateDone": "Kunden-Container aktualisiert."
|
"updateDone": "Kunden-Container aktualisiert.",
|
||||||
|
"updating": "Wird aktualisiert…",
|
||||||
|
"updateAllProgress": "Kunden-Container werden nacheinander aktualisiert — bitte warten…",
|
||||||
|
"pulling": "Wird geladen…",
|
||||||
|
"pullStartedShort": "Download im Hintergrund gestartet."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
@@ -173,6 +175,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",
|
||||||
@@ -299,7 +304,11 @@
|
|||||||
"pullStarted": "Image pull started in background. Re-checking in 5 seconds…",
|
"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.",
|
"confirmUpdateAll": "Recreate containers for all customers that have outdated images? Running services will briefly restart.",
|
||||||
"updateAllStarted": "Update started in background.",
|
"updateAllStarted": "Update started in background.",
|
||||||
"updateDone": "Customer containers updated."
|
"updateDone": "Customer containers updated.",
|
||||||
|
"updating": "Updating…",
|
||||||
|
"updateAllProgress": "Updating customer containers one by one — please wait…",
|
||||||
|
"pulling": "Pulling…",
|
||||||
|
"pullStartedShort": "Pull started in background."
|
||||||
},
|
},
|
||||||
"userModal": {
|
"userModal": {
|
||||||
"title": "New User",
|
"title": "New User",
|
||||||
|
|||||||
Reference in New Issue
Block a user