Add i18n, branding, user management, health checks, and cleanup for deployment
- Multi-language support (EN/DE) with i18n engine and language files - Configurable branding (name, subtitle, logo) in Settings - Global default language and per-user language preference - User management router with CRUD endpoints - Customer status sync on start/stop/restart - Health check fixes: derive status from container state, remove broken wget healthcheck - Caddy reverse proxy and dashboard env templates for customer stacks - Updated README with real hardware specs, prerequisites, and new features - Removed .claude settings (JWT tokens) and build artifacts from tracking - Updated .gitignore for .claude/ and Windows artifacts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(git status:*)",
|
|
||||||
"Bash(git add:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -61,3 +61,9 @@ docker-compose.override.yml
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Windows artifacts
|
||||||
|
nul
|
||||||
|
|||||||
504
README.md
504
README.md
@@ -1,8 +1,8 @@
|
|||||||
# NetBird MSP Appliance
|
# NetBird MSP Appliance
|
||||||
|
|
||||||
🚀 **Self-Hosted Multi-Tenant NetBird Management Platform**
|
**Self-Hosted Multi-Tenant NetBird Management Platform**
|
||||||
|
|
||||||
A complete management solution for running 100+ isolated NetBird instances for your MSP business. Manage all your customers' NetBird networks from a single, powerful web interface.
|
A management solution for running isolated NetBird instances for your MSP business. Manage all your customers' NetBird networks from a single web interface.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -10,123 +10,173 @@ A complete management solution for running 100+ isolated NetBird instances for y
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [System Requirements](#system-requirements)
|
- [System Requirements](#system-requirements)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Usage](#usage)
|
- [Usage](#usage)
|
||||||
- [API Documentation](#api-documentation)
|
- [API Documentation](#api-documentation)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
- [Contributing](#contributing)
|
- [Updates](#updates)
|
||||||
|
- [Security Best Practices](#security-best-practices)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## Features
|
||||||
|
|
||||||
- **🎯 Multi-Tenant Management**: Manage 100+ isolated NetBird instances from one dashboard
|
### Core
|
||||||
- **🔒 Complete Isolation**: Each customer gets their own NetBird instance with separate databases
|
- **Multi-Tenant Management** — Deploy and manage isolated NetBird instances per customer
|
||||||
- **🌐 Nginx Proxy Manager Integration**: Automatic SSL certificate management and reverse proxy setup
|
- **Complete Isolation** — Each customer gets their own NetBird stack with separate data
|
||||||
- **🐳 Docker-Based**: Everything runs in containers for easy deployment and updates
|
- **One-Click Deployment** — Deploy new customer instances in under 2 minutes
|
||||||
- **📊 Web Dashboard**: Modern, responsive UI for managing customers and deployments
|
- **Nginx Proxy Manager Integration** — Automatic SSL certificates and reverse proxy setup
|
||||||
- **🚀 One-Click Deployment**: Deploy new customer instances in under 2 minutes
|
- **Docker-Based** — Everything runs in containers for easy deployment
|
||||||
- **📈 Monitoring**: Real-time status monitoring and health checks
|
|
||||||
- **🔄 Automated Updates**: Bulk update NetBird containers across all customers
|
### Dashboard
|
||||||
- **💾 Backup & Restore**: Built-in backup functionality for all customer data
|
- **Modern Web UI** — Responsive Bootstrap 5 interface
|
||||||
- **🔐 Secure by Default**: Encrypted credentials, API tokens, and secrets management
|
- **Real-Time Monitoring** — Container status, health checks, resource usage
|
||||||
|
- **Container Logs** — View logs per container directly in the browser
|
||||||
|
- **Start / Stop / Restart** — Control customer instances from the dashboard
|
||||||
|
- **Customer Status Tracking** — Automatic status sync (active / inactive / error)
|
||||||
|
|
||||||
|
### Multi-Language (i18n)
|
||||||
|
- **English and German** — Full UI translation
|
||||||
|
- **Global Default Language** — Set a system-wide default language in Settings > Branding
|
||||||
|
- **Per-User Language** — Each user can have their own preferred language
|
||||||
|
- **Language Priority** — User preference > System default > Browser language
|
||||||
|
|
||||||
|
### Customization
|
||||||
|
- **Branding** — Configure platform name, subtitle, and logo via Settings
|
||||||
|
- **Login Page** — Branding is applied to the login page automatically
|
||||||
|
- **Configurable Docker Images** — Use custom or specific NetBird image versions
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **JWT Authentication** — Token-based API authentication
|
||||||
|
- **Azure AD / OIDC** — Optional single sign-on via Microsoft Entra ID
|
||||||
|
- **Encrypted Credentials** — NPM passwords and relay secrets are Fernet-encrypted
|
||||||
|
- **User Management** — Create, edit, and delete admin users
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
+-------------------------------------------------------------+
|
||||||
│ NetBird MSP Appliance │
|
| NetBird MSP Appliance |
|
||||||
│ │
|
| |
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
|
| +--------------+ +--------------+ +---------------+ |
|
||||||
│ │ Web GUI │───▶│ FastAPI │──▶│ SQLite DB │ │
|
| | Web GUI |--->| FastAPI |-->| SQLite DB | |
|
||||||
│ │ (Bootstrap) │ │ Backend │ │ │ │
|
| | (Bootstrap) | | Backend | | | |
|
||||||
│ └──────────────┘ └──────────────┘ └───────────────┘ │
|
| +--------------+ +--------------+ +---------------+ |
|
||||||
│ │ │
|
| | |
|
||||||
│ ┌───────────────────┼───────────────────┐ │
|
| +-------------------+-------------------+ |
|
||||||
│ ▼ ▼ ▼ │
|
| v v v |
|
||||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
| +-------------+ +-------------+ +---------------+ |
|
||||||
│ │ Docker │ │ NPM │ │ Firewall │ │
|
| | Docker | | NPM | | Template | |
|
||||||
│ │ Engine │ │ API │ │ Manager │ │
|
| | Engine | | API | | Renderer | |
|
||||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
| +-------------+ +-------------+ +---------------+ |
|
||||||
└─────────────────────────────────────────────────────────────┘
|
+-------------------------------------------------------------+
|
||||||
│
|
|
|
||||||
┌───────────────────┴───────────────────┐
|
+-------------------+-------------------+
|
||||||
▼ ▼
|
v v
|
||||||
┌──────────────────┐ ┌──────────────────┐
|
+------------------+ +------------------+
|
||||||
│ Customer 1 │ │ Customer 100 │
|
| Customer 1 | | Customer N |
|
||||||
│ ┌────────────┐ │ │ ┌────────────┐ │
|
| +------------+ | | +------------+ |
|
||||||
│ │ Management │ │ │ │ Management │ │
|
| | Management | | | | Management | |
|
||||||
│ │ Signal │ │ ... │ │ Signal │ │
|
| | Signal | | ... | | Signal | |
|
||||||
│ │ Relay │ │ │ │ Relay │ │
|
| | Relay | | | | Relay | |
|
||||||
│ │ Dashboard │ │ │ │ Dashboard │ │
|
| | Dashboard | | | | Dashboard | |
|
||||||
│ └────────────┘ │ │ └────────────┘ │
|
| | Caddy | | | | Caddy | |
|
||||||
└──────────────────┘ └──────────────────┘
|
| +------------+ | | +------------+ |
|
||||||
kunde1.domain.de kunde100.domain.de
|
+------------------+ +------------------+
|
||||||
UDP 3478 UDP 3577
|
kunde1.domain.de kundeN.domain.de
|
||||||
|
UDP 3478 UDP 3478+N-1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Components per Customer Instance:
|
### Components per Customer Instance (5 containers):
|
||||||
- **Management Service**: API and network state management
|
- **Management** — API and network state management
|
||||||
- **Signal Service**: WebRTC signaling for peer connections
|
- **Signal** — WebRTC signaling for peer connections
|
||||||
- **Relay Service**: STUN/TURN server for NAT traversal (requires public UDP port)
|
- **Relay** — STUN/TURN server for NAT traversal (requires public UDP port)
|
||||||
- **Dashboard**: Web UI for end-users
|
- **Dashboard** — Web UI for end-users
|
||||||
|
- **Caddy** — Reverse proxy / entry point for the customer stack
|
||||||
|
|
||||||
All services are accessible via HTTPS through Nginx Proxy Manager, except the Relay STUN port which requires direct UDP access.
|
All services are accessible via HTTPS through Nginx Proxy Manager, except the Relay STUN port which requires direct UDP access.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💻 System Requirements
|
## System Requirements
|
||||||
|
|
||||||
### For 100 Customers (10-20 devices per customer)
|
### Hardware Scaling
|
||||||
|
|
||||||
| Component | Minimum | Recommended | Notes |
|
Based on real-world measurements: **2 customers (11 containers) use ~220 MB RAM**.
|
||||||
|-----------|---------|-------------|-------|
|
|
||||||
| **CPU** | 8 cores | 16 cores | More cores = better concurrent deployment performance |
|
|
||||||
| **RAM** | 64 GB | 128 GB | ~600 MB per customer instance + OS overhead |
|
|
||||||
| **Storage** | 500 GB SSD | 1 TB NVMe SSD | Fast I/O critical for Docker performance |
|
|
||||||
| **Network** | 100 Mbps | 1 Gbps | Dedicated server recommended |
|
|
||||||
| **OS** | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS | Other Debian-based distros work too |
|
|
||||||
|
|
||||||
### Resource Calculation Formula:
|
Per customer instance (5 containers): **~100 MB RAM**
|
||||||
```
|
|
||||||
Per Customer Instance:
|
|
||||||
- Management: ~100 MB RAM
|
|
||||||
- Signal: ~50 MB RAM
|
|
||||||
- Relay: ~150 MB RAM
|
|
||||||
- Dashboard: ~100 MB RAM
|
|
||||||
Total: ~400-600 MB RAM per customer
|
|
||||||
|
|
||||||
For 100 customers: 40-60 GB RAM + 8 GB for OS + 8 GB for Appliance = ~64 GB minimum
|
| Customers | Container RAM | Recommended Total | vCPU | Storage |
|
||||||
```
|
|-----------|--------------|-------------------|------|---------|
|
||||||
|
| 10 | ~1.0 GB | 2 GB | 2 | 20 GB |
|
||||||
|
| 25 | ~2.5 GB | 4 GB | 2 | 50 GB |
|
||||||
|
| 50 | ~5.0 GB | 8 GB | 4 | 100 GB |
|
||||||
|
| 100 | ~10.0 GB | 16 GB | 8 | 200 GB |
|
||||||
|
| 200 | ~20.0 GB | 32 GB | 16 | 500 GB |
|
||||||
|
|
||||||
### Port Requirements:
|
> **Note:** "Recommended Total" includes OS overhead and headroom. SSD/NVMe storage is recommended for Docker performance.
|
||||||
- **TCP 8000**: NetBird MSP Appliance Web UI
|
|
||||||
- **UDP 3478-3577**: STUN/TURN relay ports (one per customer)
|
|
||||||
- Customer 1: UDP 3478
|
|
||||||
- Customer 2: UDP 3479
|
|
||||||
- ...
|
|
||||||
- Customer 100: UDP 3577
|
|
||||||
|
|
||||||
**⚠️ Important**: Your firewall must allow UDP ports 3478-3577 for full NetBird functionality!
|
### Port Requirements
|
||||||
|
|
||||||
### Prerequisites:
|
| Port | Protocol | Purpose |
|
||||||
- **Docker Engine** 24.0+ with Docker Compose plugin
|
|------|----------|---------|
|
||||||
- **Nginx Proxy Manager** (running separately or on same host)
|
| 8000 | TCP | NetBird MSP Appliance Web UI |
|
||||||
- **Domain with wildcard DNS** (e.g., `*.yourdomain.com` → your server IP)
|
| 3478+ | UDP | STUN/TURN relay (one per customer) |
|
||||||
- **Root or sudo access** to the Linux VM
|
|
||||||
|
Example: Customer 1 = UDP 3478, Customer 2 = UDP 3479, ..., Customer 100 = UDP 3577.
|
||||||
|
|
||||||
|
**Your firewall must allow the UDP relay ports for NetBird to function!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Prerequisites
|
||||||
|
|
||||||
|
The following tools and services must be available **before** running the installer.
|
||||||
|
|
||||||
|
### Required on the Host
|
||||||
|
|
||||||
|
| Tool | Purpose | Check Command |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| **Linux OS** | Ubuntu 22.04+, Debian 12+, or similar | `cat /etc/os-release` |
|
||||||
|
| **sudo / root** | Installation requires root privileges | `sudo -v` |
|
||||||
|
| **curl** | Used by the installer to install Docker | `curl --version` |
|
||||||
|
| **git** | Clone the repository | `git --version` |
|
||||||
|
| **openssl** | Generate encryption keys during install | `openssl version` |
|
||||||
|
|
||||||
|
### Installed Automatically
|
||||||
|
|
||||||
|
| Tool | Purpose | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| **Docker Engine** 24.0+ | Container runtime | Installed by `install.sh` if missing |
|
||||||
|
| **Docker Compose Plugin** | Multi-container orchestration | Installed with Docker |
|
||||||
|
|
||||||
|
### External Services
|
||||||
|
|
||||||
|
| Service | Purpose | Notes |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| **Nginx Proxy Manager** | Reverse proxy + SSL certificates | Must be running and accessible from the host. Can be on the same server or a separate one. |
|
||||||
|
| **Wildcard DNS** | Route `*.yourdomain.com` to the server | Configure `*.yourdomain.com` as an A record pointing to your server IP |
|
||||||
|
|
||||||
|
### Install Prerequisites (Ubuntu/Debian)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y curl git openssl
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
### 1. Clone the Repository
|
### 1. Clone the Repository
|
||||||
|
|
||||||
@@ -143,23 +193,23 @@ sudo ./install.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
The installer will **interactively ask you** for:
|
The installer will **interactively ask you** for:
|
||||||
- ✅ Admin username and password
|
- Admin username and password
|
||||||
- ✅ Admin email address
|
- Admin email address
|
||||||
- ✅ Base domain (e.g., `yourdomain.com`)
|
- Base domain (e.g., `yourdomain.com`)
|
||||||
- ✅ Nginx Proxy Manager API URL and token
|
- Nginx Proxy Manager API URL, email, and password
|
||||||
- ✅ Data directory location
|
- Data directory location
|
||||||
- ✅ NetBird Docker images (optional customization)
|
- NetBird Docker images (optional customization)
|
||||||
|
|
||||||
**No manual .env file editing required!** Everything is configured through the installation wizard.
|
**No manual .env file editing required!** All configuration is stored in the database and editable via the Web UI.
|
||||||
|
|
||||||
The installer will then:
|
The installer will then:
|
||||||
- ✅ Check system requirements
|
- Check system requirements
|
||||||
- ✅ Install Docker if needed
|
- Install Docker if needed
|
||||||
- ✅ Create directories and Docker network
|
- Create directories and Docker network
|
||||||
- ✅ Generate encryption keys
|
- Generate encryption keys
|
||||||
- ✅ Build and start all containers
|
- Build and start all containers
|
||||||
- ✅ Configure firewall (optional)
|
- Seed configuration into the database
|
||||||
- ✅ Initialize the database
|
- Optionally configure the firewall (ufw)
|
||||||
|
|
||||||
### 3. Access the Web Interface
|
### 3. Access the Web Interface
|
||||||
|
|
||||||
@@ -170,85 +220,61 @@ http://your-server-ip:8000
|
|||||||
|
|
||||||
Login with the credentials you provided during installation.
|
Login with the credentials you provided during installation.
|
||||||
|
|
||||||
**All settings can be changed later via the Web UI!**
|
|
||||||
|
|
||||||
### 4. Deploy Your First Customer
|
### 4. Deploy Your First Customer
|
||||||
|
|
||||||
1. Click **"New Customer"** button
|
1. Click **"New Customer"** button
|
||||||
2. Fill in customer details:
|
2. Fill in customer details (name, subdomain, email, max devices)
|
||||||
- Name
|
|
||||||
- Subdomain (e.g., `customer1` → `customer1.yourdomain.com`)
|
|
||||||
- Email
|
|
||||||
- Max Devices
|
|
||||||
3. Click **"Deploy"**
|
3. Click **"Deploy"**
|
||||||
4. Wait ~60-90 seconds
|
4. Wait ~60-90 seconds
|
||||||
5. Done! ✅
|
5. Done!
|
||||||
|
|
||||||
The system will automatically:
|
The system will automatically:
|
||||||
- Assign a unique UDP port for the relay
|
- Assign a unique UDP port for the relay
|
||||||
- Generate all config files
|
- Generate all config files from templates
|
||||||
- Start Docker containers
|
- Start the 5 Docker containers
|
||||||
- Create NPM proxy hosts with SSL
|
- Create NPM proxy hosts with SSL
|
||||||
- Provide the setup URL
|
- Provide the setup URL for the customer
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Create a `.env` file (or use the one generated by installer):
|
The installer generates a minimal `.env` file with container-level variables only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Security
|
SECRET_KEY=<auto-generated>
|
||||||
SECRET_KEY=your-secure-random-key-here
|
DATABASE_PATH=/app/data/netbird_msp.db
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=your-secure-password
|
|
||||||
|
|
||||||
# Nginx Proxy Manager
|
|
||||||
NPM_API_URL=http://nginx-proxy-manager:81/api
|
|
||||||
NPM_API_TOKEN=your-npm-api-token
|
|
||||||
|
|
||||||
# System
|
|
||||||
DATA_DIR=/opt/netbird-instances
|
DATA_DIR=/opt/netbird-instances
|
||||||
DOCKER_NETWORK=npm-network
|
DOCKER_NETWORK=npm-network
|
||||||
BASE_DOMAIN=yourdomain.com
|
|
||||||
ADMIN_EMAIL=admin@yourdomain.com
|
|
||||||
|
|
||||||
# NetBird Images (optional - defaults to latest)
|
|
||||||
NETBIRD_MANAGEMENT_IMAGE=netbirdio/management:latest
|
|
||||||
NETBIRD_SIGNAL_IMAGE=netbirdio/signal:latest
|
|
||||||
NETBIRD_RELAY_IMAGE=netbirdio/relay:latest
|
|
||||||
NETBIRD_DASHBOARD_IMAGE=netbirdio/dashboard:latest
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_PATH=/app/data/netbird_msp.db
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
WEB_UI_PORT=8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### System Configuration via Web UI
|
> **All application settings** (domain, NPM credentials, Docker images, branding, etc.) are stored in the SQLite database and editable via the Web UI under **Settings**.
|
||||||
|
|
||||||
All settings can be configured through the web interface under **Settings** → **System Configuration**:
|
### Web UI Settings
|
||||||
|
|
||||||
- Base Domain
|
Available under **Settings** in the web interface:
|
||||||
- Admin Email
|
|
||||||
- NPM Integration
|
| Tab | Settings |
|
||||||
- Docker Images
|
|-----|----------|
|
||||||
- Port Ranges
|
| **System** | Base domain, admin email, NPM credentials, Docker images, port ranges, data directory |
|
||||||
- Data Directories
|
| **Branding** | Platform name, subtitle, logo upload, default language |
|
||||||
|
| **Users** | Create/edit/delete admin users, per-user language preference |
|
||||||
|
| **Monitoring** | System resources, Docker stats |
|
||||||
|
|
||||||
Changes are applied immediately without restart.
|
Changes are applied immediately without restart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 Usage
|
## Usage
|
||||||
|
|
||||||
### Managing Customers
|
### Managing Customers
|
||||||
|
|
||||||
#### Create a New Customer
|
#### Create a New Customer
|
||||||
1. Dashboard → **New Customer**
|
1. Dashboard > **New Customer**
|
||||||
2. Fill in details
|
2. Fill in details
|
||||||
3. Click **Deploy**
|
3. Click **Deploy**
|
||||||
4. Share the setup URL with your customer
|
4. Share the setup URL with your customer
|
||||||
@@ -259,55 +285,41 @@ Changes are applied immediately without restart.
|
|||||||
- Copy setup URL and credentials
|
- Copy setup URL and credentials
|
||||||
|
|
||||||
#### Start / Stop / Restart Containers
|
#### Start / Stop / Restart Containers
|
||||||
- Click the action buttons in the customer list
|
- Use the action buttons in the customer detail view
|
||||||
- Or use the detail view for more control
|
- Stopping all containers sets the customer status to "inactive"
|
||||||
|
- Starting containers sets the status back to "active"
|
||||||
|
|
||||||
#### Delete a Customer
|
#### Delete a Customer
|
||||||
- Click **Delete** → Confirm
|
- Click **Delete** > Confirm
|
||||||
- All containers, data, and NPM entries are removed
|
- All containers, data, and NPM entries are removed
|
||||||
|
|
||||||
### Monitoring
|
### Monitoring
|
||||||
|
|
||||||
The dashboard shows:
|
The dashboard shows:
|
||||||
- **System Overview**: Total customers, active/inactive, errors
|
- **System Overview** — Total customers, active/inactive, errors
|
||||||
- **Resource Usage**: RAM, CPU, disk usage
|
- **Resource Usage** — RAM, CPU per container
|
||||||
- **Container Status**: Running/stopped/failed
|
- **Container Health** — Running/stopped per container with color-coded status
|
||||||
- **Recent Activity**: Deployment logs and events
|
- **Deployment Logs** — Action history per customer
|
||||||
|
|
||||||
### Bulk Operations
|
### Language Settings
|
||||||
|
|
||||||
Select multiple customers using checkboxes:
|
- **Switch language** — Use the language switcher in the top navigation bar
|
||||||
- **Bulk Update**: Update NetBird images across selected customers
|
- **Per-user default** — Set in Settings > Users during user creation
|
||||||
- **Bulk Restart**: Restart all selected instances
|
- **System default** — Set in Settings > Branding
|
||||||
- **Bulk Backup**: Create backups of selected customers
|
|
||||||
|
|
||||||
### Backups
|
|
||||||
|
|
||||||
#### Manual Backup
|
|
||||||
```bash
|
|
||||||
docker exec netbird-msp-appliance python -m app.backup --customer-id 1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Automatic Backups
|
|
||||||
Configure in Settings → Backup:
|
|
||||||
- Schedule: Daily/Weekly
|
|
||||||
- Retention: Number of backups to keep
|
|
||||||
- Destination: Local path or remote storage
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔌 API Documentation
|
## API Documentation
|
||||||
|
|
||||||
The appliance provides a REST API for automation.
|
The appliance provides a REST API.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
```bash
|
```bash
|
||||||
# Get API token (after login)
|
|
||||||
curl -X POST http://localhost:8000/api/auth/token \
|
curl -X POST http://localhost:8000/api/auth/token \
|
||||||
-d "username=admin&password=yourpassword"
|
-d "username=admin&password=yourpassword"
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Endpoints
|
### Endpoints
|
||||||
|
|
||||||
Full interactive documentation available at:
|
Full interactive documentation available at:
|
||||||
```
|
```
|
||||||
@@ -324,10 +336,14 @@ 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
|
||||||
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
|
||||||
|
|
||||||
GET /api/status # System status
|
GET /api/settings/branding # Get branding (public, no auth)
|
||||||
|
PUT /api/settings # Update system settings
|
||||||
|
GET /api/users # List users
|
||||||
|
POST /api/users # Create user
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example: Create Customer via API
|
### Example: Create Customer via API
|
||||||
@@ -345,134 +361,96 @@ curl -X POST http://localhost:8000/api/customers \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Customer deployment fails
|
||||||
|
|
||||||
#### 1. Customer deployment fails
|
|
||||||
**Symptom**: Status shows "error" after deployment
|
**Symptom**: Status shows "error" after deployment
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
- Check Docker logs: `docker logs netbird-msp-appliance`
|
- Check Docker logs: `docker logs netbird-msp-appliance`
|
||||||
- Verify NPM is accessible: `curl http://npm-host:81/api`
|
- Verify NPM is accessible from the appliance container
|
||||||
- Check available UDP ports: `netstat -ulnp | grep 347`
|
- Check available UDP ports: `ss -ulnp | grep 347`
|
||||||
- View detailed logs in the customer detail page
|
- View detailed logs in the customer detail page (Logs tab)
|
||||||
|
|
||||||
#### 2. NetBird clients can't connect
|
### NetBird clients can't connect
|
||||||
**Symptom**: Clients show "relay unavailable"
|
**Symptom**: Clients show "relay unavailable"
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
- **Most common**: UDP port not open in firewall
|
- **Most common**: UDP port not open in firewall
|
||||||
```bash
|
```bash
|
||||||
# Check if port is open
|
|
||||||
sudo ufw status
|
|
||||||
|
|
||||||
# Open the relay port
|
|
||||||
sudo ufw allow 3478/udp
|
sudo ufw allow 3478/udp
|
||||||
```
|
```
|
||||||
- Verify relay container is running: `docker ps | grep relay`
|
- Verify relay container is running: `docker ps | grep relay`
|
||||||
- Test STUN server: Use online STUN tester with your port
|
|
||||||
|
|
||||||
#### 3. NPM integration not working
|
### NPM integration not working
|
||||||
**Symptom**: SSL certificates not created
|
**Symptom**: Proxy hosts or SSL certificates not created
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
- Verify NPM API token is correct
|
- Verify NPM email and password are correct in Settings
|
||||||
- Check NPM is on same Docker network: `npm-network`
|
- Check NPM is on same Docker network (`npm-network`)
|
||||||
- Test NPM API manually:
|
- Check NPM logs for errors
|
||||||
```bash
|
|
||||||
curl -X GET http://npm-host:81/api/nginx/proxy-hosts \
|
|
||||||
-H "Authorization: Bearer YOUR_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Out of memory errors
|
|
||||||
**Symptom**: Containers crashing, system slow
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
- Check RAM usage: `free -h`
|
|
||||||
- Reduce number of customers or upgrade RAM
|
|
||||||
- Stop inactive customer instances
|
|
||||||
- Configure swap space:
|
|
||||||
```bash
|
|
||||||
sudo fallocate -l 16G /swapfile
|
|
||||||
sudo chmod 600 /swapfile
|
|
||||||
sudo mkswap /swapfile
|
|
||||||
sudo swapon /swapfile
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debug Mode
|
### Debug Mode
|
||||||
|
|
||||||
Enable debug logging:
|
Enable debug logging:
|
||||||
```bash
|
```bash
|
||||||
# Edit .env
|
# In your .env file:
|
||||||
LOG_LEVEL=DEBUG
|
LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
# Restart
|
# Restart the appliance:
|
||||||
docker-compose restart
|
docker compose restart
|
||||||
```
|
```
|
||||||
|
|
||||||
View detailed logs:
|
View logs:
|
||||||
```bash
|
```bash
|
||||||
docker logs -f netbird-msp-appliance
|
docker logs -f netbird-msp-appliance
|
||||||
```
|
```
|
||||||
|
|
||||||
### Getting Help
|
|
||||||
|
|
||||||
1. **Check the logs**: Most issues are explained in the logs
|
|
||||||
2. **GitHub Issues**: Search existing issues or create a new one
|
|
||||||
3. **NetBird Community**: For NetBird-specific questions
|
|
||||||
4. **Documentation**: Read the full docs in `/docs` folder
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 Updates
|
## Updates
|
||||||
|
|
||||||
### Updating the Appliance
|
### Updating the Appliance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd netbird-msp-appliance
|
cd /opt/netbird-msp
|
||||||
git pull
|
git pull
|
||||||
docker-compose down
|
docker compose down
|
||||||
docker-compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The database migrations run automatically on startup.
|
||||||
|
|
||||||
### Updating NetBird Images
|
### Updating NetBird Images
|
||||||
|
|
||||||
**Via Web UI**:
|
Via the Web UI:
|
||||||
1. Settings → System Configuration
|
1. Settings > System Configuration
|
||||||
2. Update image tags
|
2. Change image tags (e.g., `netbirdio/management:0.35.0`)
|
||||||
3. Click "Save"
|
3. Click "Save"
|
||||||
4. Use Bulk Update for customers
|
4. Re-deploy individual customers to apply the new images
|
||||||
|
|
||||||
**Via CLI**:
|
|
||||||
```bash
|
|
||||||
# Update all customer instances
|
|
||||||
docker exec netbird-msp-appliance python -m app.update --all
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛡️ Security Best Practices
|
## Security Best Practices
|
||||||
|
|
||||||
1. **Change default credentials** immediately after installation
|
1. **Change default credentials** immediately after installation
|
||||||
2. **Use strong passwords** (20+ characters, mixed case, numbers, symbols)
|
2. **Use strong passwords** (12+ characters recommended)
|
||||||
3. **Keep NPM API token secure** - never commit to git
|
3. **Keep NPM credentials secure** — they are stored encrypted in the database
|
||||||
4. **Enable firewall** and only open required ports
|
4. **Enable firewall** and only open required ports (TCP 8000, UDP relay range)
|
||||||
5. **Regular updates** - both the appliance and NetBird images
|
5. **Use HTTPS** — put the MSP appliance behind a reverse proxy with SSL
|
||||||
6. **Backup regularly** - automate daily backups
|
6. **Regular updates** — both the appliance and NetBird images
|
||||||
7. **Use HTTPS** - always access the web UI via HTTPS (configure reverse proxy)
|
7. **Backup your database** — `data/netbird_msp.db` contains all configuration
|
||||||
8. **Monitor logs** - check for suspicious activity
|
8. **Monitor logs** — check for suspicious activity
|
||||||
9. **Limit access** - use VPN or IP whitelist for the management interface
|
9. **Restrict access** — use VPN or IP whitelist for the management interface
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Performance Tuning
|
## Performance Tuning
|
||||||
|
|
||||||
### For 100+ Customers:
|
### For 100+ Customers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Increase Docker ulimits
|
# Increase Docker ulimits — add to /etc/docker/daemon.json
|
||||||
# Add to /etc/docker/daemon.json
|
|
||||||
{
|
{
|
||||||
"default-ulimits": {
|
"default-ulimits": {
|
||||||
"nofile": {
|
"nofile": {
|
||||||
@@ -494,32 +472,14 @@ sudo sysctl -p
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🤝 Contributing
|
## License
|
||||||
|
|
||||||
Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
MIT License — see [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 License
|
## Acknowledgments
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE) file for details.
|
- [NetBird](https://netbird.io/) — Open-source VPN solution
|
||||||
|
- [FastAPI](https://fastapi.tiangolo.com/) — High-performance Python framework
|
||||||
---
|
- [Nginx Proxy Manager](https://nginxproxymanager.com/) — Reverse proxy management
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
|
||||||
|
|
||||||
- **NetBird Team** - for the amazing open-source VPN solution
|
|
||||||
- **FastAPI** - for the high-performance Python framework
|
|
||||||
- **Nginx Proxy Manager** - for easy reverse proxy management
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/yourusername/netbird-msp-appliance/issues)
|
|
||||||
- **Discussions**: [GitHub Discussions](https://github.com/yourusername/netbird-msp-appliance/discussions)
|
|
||||||
- **Email**: support@yourdomain.com
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Made with ❤️ for MSPs and System Administrators**
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def get_db() -> Generator[Session, None, None]:
|
|||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
"""Create all database tables."""
|
"""Create all database tables and run lightweight migrations."""
|
||||||
from app.models import ( # noqa: F401
|
from app.models import ( # noqa: F401
|
||||||
Customer,
|
Customer,
|
||||||
Deployment,
|
Deployment,
|
||||||
@@ -49,6 +49,43 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_migrations() -> None:
|
||||||
|
"""Add columns that may be missing from older database versions."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
def _has_column(table: str, column: str) -> bool:
|
||||||
|
cursor.execute(f"PRAGMA table_info({table})")
|
||||||
|
return any(row[1] == column for row in cursor.fetchall())
|
||||||
|
|
||||||
|
migrations = [
|
||||||
|
("deployments", "dashboard_port", "INTEGER"),
|
||||||
|
("system_config", "dashboard_base_port", "INTEGER DEFAULT 9000"),
|
||||||
|
("deployments", "netbird_admin_email", "TEXT"),
|
||||||
|
("deployments", "netbird_admin_password", "TEXT"),
|
||||||
|
("system_config", "branding_name", "TEXT DEFAULT 'NetBird MSP Appliance'"),
|
||||||
|
("system_config", "branding_logo_path", "TEXT"),
|
||||||
|
("users", "role", "TEXT DEFAULT 'admin'"),
|
||||||
|
("users", "auth_provider", "TEXT DEFAULT 'local'"),
|
||||||
|
("system_config", "azure_enabled", "BOOLEAN DEFAULT 0"),
|
||||||
|
("system_config", "azure_tenant_id", "TEXT"),
|
||||||
|
("system_config", "azure_client_id", "TEXT"),
|
||||||
|
("system_config", "azure_client_secret_encrypted", "TEXT"),
|
||||||
|
("system_config", "branding_subtitle", "TEXT DEFAULT 'Multi-Tenant Management Platform'"),
|
||||||
|
("system_config", "default_language", "TEXT DEFAULT 'en'"),
|
||||||
|
("users", "default_language", "TEXT"),
|
||||||
|
]
|
||||||
|
for table, column, col_type in migrations:
|
||||||
|
if not _has_column(table, column):
|
||||||
|
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.routers import auth, customers, deployments, monitoring, settings
|
from app.routers import auth, customers, deployments, monitoring, settings, users
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -50,6 +50,7 @@ app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
|||||||
app.include_router(customers.router, prefix="/api/customers", tags=["Customers"])
|
app.include_router(customers.router, prefix="/api/customers", tags=["Customers"])
|
||||||
app.include_router(deployments.router, prefix="/api/customers", tags=["Deployments"])
|
app.include_router(deployments.router, prefix="/api/customers", tags=["Deployments"])
|
||||||
app.include_router(monitoring.router, prefix="/api/monitoring", tags=["Monitoring"])
|
app.include_router(monitoring.router, prefix="/api/monitoring", tags=["Monitoring"])
|
||||||
|
app.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Static files — serve the frontend SPA
|
# Static files — serve the frontend SPA
|
||||||
|
|||||||
@@ -81,9 +81,12 @@ class Deployment(Base):
|
|||||||
)
|
)
|
||||||
container_prefix: Mapped[str] = mapped_column(String(100), nullable=False)
|
container_prefix: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
relay_udp_port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
relay_udp_port: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
||||||
|
dashboard_port: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
npm_proxy_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
npm_proxy_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
relay_secret: Mapped[str] = mapped_column(Text, nullable=False)
|
relay_secret: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
setup_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
setup_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
netbird_admin_email: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
netbird_admin_password: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
deployment_status: Mapped[str] = mapped_column(
|
deployment_status: Mapped[str] = mapped_column(
|
||||||
String(20), default="pending", nullable=False
|
String(20), default="pending", nullable=False
|
||||||
)
|
)
|
||||||
@@ -106,9 +109,11 @@ class Deployment(Base):
|
|||||||
"customer_id": self.customer_id,
|
"customer_id": self.customer_id,
|
||||||
"container_prefix": self.container_prefix,
|
"container_prefix": self.container_prefix,
|
||||||
"relay_udp_port": self.relay_udp_port,
|
"relay_udp_port": self.relay_udp_port,
|
||||||
|
"dashboard_port": self.dashboard_port,
|
||||||
"npm_proxy_id": self.npm_proxy_id,
|
"npm_proxy_id": self.npm_proxy_id,
|
||||||
"relay_secret": "***", # Never expose secrets
|
"relay_secret": "***", # Never expose secrets
|
||||||
"setup_url": self.setup_url,
|
"setup_url": self.setup_url,
|
||||||
|
"has_credentials": bool(self.netbird_admin_email and self.netbird_admin_password),
|
||||||
"deployment_status": self.deployment_status,
|
"deployment_status": self.deployment_status,
|
||||||
"deployed_at": self.deployed_at.isoformat() if self.deployed_at else None,
|
"deployed_at": self.deployed_at.isoformat() if self.deployed_at else None,
|
||||||
"last_health_check": (
|
"last_health_check": (
|
||||||
@@ -145,6 +150,19 @@ class SystemConfig(Base):
|
|||||||
data_dir: Mapped[str] = mapped_column(String(500), default="/opt/netbird-instances")
|
data_dir: Mapped[str] = mapped_column(String(500), default="/opt/netbird-instances")
|
||||||
docker_network: Mapped[str] = mapped_column(String(100), default="npm-network")
|
docker_network: Mapped[str] = mapped_column(String(100), default="npm-network")
|
||||||
relay_base_port: Mapped[int] = mapped_column(Integer, default=3478)
|
relay_base_port: Mapped[int] = mapped_column(Integer, default=3478)
|
||||||
|
dashboard_base_port: Mapped[int] = mapped_column(Integer, default=9000)
|
||||||
|
branding_name: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255), default="NetBird MSP Appliance"
|
||||||
|
)
|
||||||
|
branding_subtitle: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255), default="Multi-Tenant Management Platform"
|
||||||
|
)
|
||||||
|
branding_logo_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
default_language: Mapped[Optional[str]] = mapped_column(String(10), default="en")
|
||||||
|
azure_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
azure_tenant_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
azure_client_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
azure_client_secret_encrypted: Mapped[Optional[str]] = mapped_column(Text, 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
|
||||||
@@ -168,6 +186,15 @@ class SystemConfig(Base):
|
|||||||
"data_dir": self.data_dir,
|
"data_dir": self.data_dir,
|
||||||
"docker_network": self.docker_network,
|
"docker_network": self.docker_network,
|
||||||
"relay_base_port": self.relay_base_port,
|
"relay_base_port": self.relay_base_port,
|
||||||
|
"dashboard_base_port": self.dashboard_base_port,
|
||||||
|
"branding_name": self.branding_name or "NetBird MSP Appliance",
|
||||||
|
"branding_subtitle": self.branding_subtitle or "Multi-Tenant Management Platform",
|
||||||
|
"branding_logo_path": self.branding_logo_path,
|
||||||
|
"default_language": self.default_language or "en",
|
||||||
|
"azure_enabled": bool(self.azure_enabled),
|
||||||
|
"azure_tenant_id": self.azure_tenant_id or "",
|
||||||
|
"azure_client_id": self.azure_client_id or "",
|
||||||
|
"azure_client_secret_set": bool(self.azure_client_secret_encrypted),
|
||||||
"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,
|
||||||
}
|
}
|
||||||
@@ -220,6 +247,9 @@ class User(Base):
|
|||||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default="admin")
|
||||||
|
auth_provider: Mapped[str] = mapped_column(String(20), default="local")
|
||||||
|
default_language: Mapped[Optional[str]] = mapped_column(String(10), nullable=True, default=None)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
@@ -229,5 +259,8 @@ class User(Base):
|
|||||||
"username": self.username,
|
"username": self.username,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"is_active": self.is_active,
|
"is_active": self.is_active,
|
||||||
|
"role": self.role or "admin",
|
||||||
|
"auth_provider": self.auth_provider or "local",
|
||||||
|
"default_language": self.default_language,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
"""Authentication API endpoints — login, logout, current user, password change."""
|
"""Authentication API endpoints — login, logout, current user, password change, Azure AD."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import create_access_token, get_current_user
|
from app.dependencies import create_access_token, get_current_user
|
||||||
from app.models import User
|
from app.models import SystemConfig, User
|
||||||
from app.utils.security import hash_password, verify_password
|
from app.utils.security import decrypt_value, hash_password, verify_password
|
||||||
from app.utils.validators import ChangePasswordRequest, LoginRequest
|
from app.utils.validators import ChangePasswordRequest, LoginRequest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -95,3 +97,115 @@ async def change_password(
|
|||||||
db.commit()
|
db.commit()
|
||||||
logger.info("Password changed for user %s.", current_user.username)
|
logger.info("Password changed for user %s.", current_user.username)
|
||||||
return {"message": "Password changed successfully."}
|
return {"message": "Password changed successfully."}
|
||||||
|
|
||||||
|
|
||||||
|
class AzureCallbackRequest(BaseModel):
|
||||||
|
"""Azure AD auth code callback payload."""
|
||||||
|
code: str
|
||||||
|
redirect_uri: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/azure/config")
|
||||||
|
async def get_azure_config(db: Session = Depends(get_db)):
|
||||||
|
"""Public endpoint — returns Azure AD config for the login page."""
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not config or not config.azure_enabled:
|
||||||
|
return {"azure_enabled": False}
|
||||||
|
return {
|
||||||
|
"azure_enabled": True,
|
||||||
|
"azure_tenant_id": config.azure_tenant_id,
|
||||||
|
"azure_client_id": config.azure_client_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/azure/callback")
|
||||||
|
async def azure_callback(
|
||||||
|
payload: AzureCallbackRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Exchange Azure AD authorization code for tokens and authenticate."""
|
||||||
|
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not config or not config.azure_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Azure AD authentication is not enabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not config.azure_tenant_id or not config.azure_client_id or not config.azure_client_secret_encrypted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Azure AD is not fully configured.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import msal
|
||||||
|
|
||||||
|
client_secret = decrypt_value(config.azure_client_secret_encrypted)
|
||||||
|
authority = f"https://login.microsoftonline.com/{config.azure_tenant_id}"
|
||||||
|
|
||||||
|
app = msal.ConfidentialClientApplication(
|
||||||
|
config.azure_client_id,
|
||||||
|
authority=authority,
|
||||||
|
client_credential=client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = app.acquire_token_by_authorization_code(
|
||||||
|
payload.code,
|
||||||
|
scopes=["User.Read"],
|
||||||
|
redirect_uri=payload.redirect_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
logger.warning("Azure AD token exchange failed: %s", result.get("error_description", result["error"]))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=result.get("error_description", "Azure AD authentication failed."),
|
||||||
|
)
|
||||||
|
|
||||||
|
id_token_claims = result.get("id_token_claims", {})
|
||||||
|
email = id_token_claims.get("preferred_username") or id_token_claims.get("email", "")
|
||||||
|
display_name = id_token_claims.get("name", email)
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Could not determine email from Azure AD token.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find or create user
|
||||||
|
user = db.query(User).filter(User.username == email).first()
|
||||||
|
if not user:
|
||||||
|
user = User(
|
||||||
|
username=email,
|
||||||
|
password_hash=hash_password(secrets.token_urlsafe(32)),
|
||||||
|
email=email,
|
||||||
|
is_active=True,
|
||||||
|
role="admin",
|
||||||
|
auth_provider="azure",
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
logger.info("Azure AD user '%s' auto-created.", email)
|
||||||
|
elif not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Account is disabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = create_access_token(user.username)
|
||||||
|
logger.info("Azure AD user '%s' logged in.", user.username)
|
||||||
|
return {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": user.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Azure AD authentication error")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Azure AD authentication error: {exc}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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, User
|
||||||
from app.services import docker_service, netbird_service
|
from app.services import docker_service, netbird_service
|
||||||
|
from app.utils.security import decrypt_value
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -174,6 +175,38 @@ async def check_customer_health(
|
|||||||
return netbird_service.get_customer_health(db, customer_id)
|
return netbird_service.get_customer_health(db, customer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}/credentials")
|
||||||
|
async def get_customer_credentials(
|
||||||
|
customer_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get the NetBird admin credentials for a customer's deployment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Customer ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with email and password.
|
||||||
|
"""
|
||||||
|
_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.",
|
||||||
|
)
|
||||||
|
if not deployment.netbird_admin_email or not deployment.netbird_admin_password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No credentials available. Admin must complete setup manually.",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"email": decrypt_value(deployment.netbird_admin_email),
|
||||||
|
"password": decrypt_value(deployment.netbird_admin_password),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ async def all_customers_status(
|
|||||||
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
|
||||||
|
entry["dashboard_port"] = c.deployment.dashboard_port
|
||||||
entry["setup_url"] = c.deployment.setup_url
|
entry["setup_url"] = c.deployment.setup_url
|
||||||
else:
|
else:
|
||||||
entry["deployment_status"] = None
|
entry["deployment_status"] = None
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ There is no .env file. Every setting lives in the ``system_config`` table
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -21,6 +23,10 @@ from app.utils.validators import SystemConfigUpdate
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "uploads")
|
||||||
|
MAX_LOGO_SIZE = 512 * 1024 # 500 KB
|
||||||
|
ALLOWED_LOGO_TYPES = {"image/png", "image/jpeg", "image/svg+xml"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/system")
|
@router.get("/system")
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
@@ -75,6 +81,11 @@ async def update_settings(
|
|||||||
raw_password = update_data.pop("npm_api_password")
|
raw_password = update_data.pop("npm_api_password")
|
||||||
row.npm_api_password_encrypted = encrypt_value(raw_password)
|
row.npm_api_password_encrypted = encrypt_value(raw_password)
|
||||||
|
|
||||||
|
# Handle Azure client secret encryption
|
||||||
|
if "azure_client_secret" in update_data:
|
||||||
|
raw_secret = update_data.pop("azure_client_secret")
|
||||||
|
row.azure_client_secret_encrypted = encrypt_value(raw_secret)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if hasattr(row, field):
|
if hasattr(row, field):
|
||||||
setattr(row, field, value)
|
setattr(row, field, value)
|
||||||
@@ -116,3 +127,85 @@ async def test_npm(
|
|||||||
config.npm_api_url, config.npm_api_email, config.npm_api_password
|
config.npm_api_url, config.npm_api_email, config.npm_api_password
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/branding")
|
||||||
|
async def get_branding(db: Session = Depends(get_db)):
|
||||||
|
"""Public endpoint — returns branding info for the login page (no auth required)."""
|
||||||
|
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"branding_name": "NetBird MSP Appliance",
|
||||||
|
"branding_subtitle": "Multi-Tenant Management Platform",
|
||||||
|
"branding_logo_path": None,
|
||||||
|
"default_language": "en",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"branding_name": row.branding_name or "NetBird MSP Appliance",
|
||||||
|
"branding_subtitle": row.branding_subtitle or "Multi-Tenant Management Platform",
|
||||||
|
"branding_logo_path": row.branding_logo_path,
|
||||||
|
"default_language": row.default_language or "en",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/branding/logo")
|
||||||
|
async def upload_logo(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Upload a branding logo image (PNG, JPG, SVG, max 500KB)."""
|
||||||
|
if file.content_type not in ALLOWED_LOGO_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File type '{file.content_type}' not allowed. Use PNG, JPG, or SVG.",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > MAX_LOGO_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File too large ({len(content)} bytes). Maximum is {MAX_LOGO_SIZE} bytes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/svg+xml": ".svg"}
|
||||||
|
ext = ext_map.get(file.content_type, ".png")
|
||||||
|
filename = f"logo{ext}"
|
||||||
|
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||||
|
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
logo_url = f"/static/uploads/{filename}"
|
||||||
|
|
||||||
|
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if row:
|
||||||
|
row.branding_logo_path = logo_url
|
||||||
|
row.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("Logo uploaded by %s: %s", current_user.username, logo_url)
|
||||||
|
return {"branding_logo_path": logo_url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/branding/logo")
|
||||||
|
async def delete_logo(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Remove the branding logo and reset to default icon."""
|
||||||
|
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
|
if row and row.branding_logo_path:
|
||||||
|
old_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||||
|
row.branding_logo_path.lstrip("/"),
|
||||||
|
)
|
||||||
|
if os.path.isfile(old_path):
|
||||||
|
os.remove(old_path)
|
||||||
|
row.branding_logo_path = None
|
||||||
|
row.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"branding_logo_path": None}
|
||||||
|
|||||||
131
app/routers/users.py
Normal file
131
app/routers/users.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""User management API — CRUD operations for local users."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.models import User
|
||||||
|
from app.utils.security import hash_password
|
||||||
|
from app.utils.validators import UserCreate, UserUpdate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all users."""
|
||||||
|
users = db.query(User).order_by(User.id).all()
|
||||||
|
return [u.to_dict() for u in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_user(
|
||||||
|
payload: UserCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new local user."""
|
||||||
|
existing = db.query(User).filter(User.username == payload.username).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Username '{payload.username}' already exists.",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=payload.username,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
email=payload.email,
|
||||||
|
is_active=True,
|
||||||
|
role="admin",
|
||||||
|
auth_provider="local",
|
||||||
|
default_language=payload.default_language,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
logger.info("User '%s' created by '%s'.", user.username, current_user.username)
|
||||||
|
return user.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}")
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
payload: UserUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an existing user (email, is_active, role)."""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
||||||
|
|
||||||
|
update_data = payload.model_dump(exclude_none=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if hasattr(user, field):
|
||||||
|
setattr(user, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
logger.info("User '%s' updated by '%s'.", user.username, current_user.username)
|
||||||
|
return user.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a user (cannot delete yourself)."""
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="You cannot delete your own account.",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
||||||
|
|
||||||
|
username = user.username
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("User '%s' deleted by '%s'.", username, current_user.username)
|
||||||
|
return {"message": f"User '{username}' deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/reset-password")
|
||||||
|
async def reset_password(
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Generate a new random password for a user."""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
|
||||||
|
|
||||||
|
if user.auth_provider != "local":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot reset password for Azure AD users.",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_password = secrets.token_urlsafe(16)
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info("Password reset for user '%s' by '%s'.", user.username, current_user.username)
|
||||||
|
return {"message": "Password reset successfully.", "new_password": new_password}
|
||||||
@@ -26,12 +26,20 @@ def _get_client() -> docker.DockerClient:
|
|||||||
return docker.from_env()
|
return docker.from_env()
|
||||||
|
|
||||||
|
|
||||||
def compose_up(instance_dir: str, project_name: str) -> bool:
|
def compose_up(
|
||||||
|
instance_dir: str,
|
||||||
|
project_name: str,
|
||||||
|
services: Optional[list[str]] = None,
|
||||||
|
timeout: int = 300,
|
||||||
|
) -> bool:
|
||||||
"""Run ``docker compose up -d`` for a customer instance.
|
"""Run ``docker compose up -d`` for a customer instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_dir: Absolute path to the customer's instance directory.
|
instance_dir: Absolute path to the customer's instance directory.
|
||||||
project_name: Docker Compose project name (e.g. ``netbird-kunde5``).
|
project_name: Docker Compose project name (e.g. ``netbird-kunde5``).
|
||||||
|
services: Optional list of service names to start.
|
||||||
|
If None, all services are started.
|
||||||
|
timeout: Subprocess timeout in seconds (default 300).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True on success.
|
True on success.
|
||||||
@@ -47,16 +55,22 @@ def compose_up(instance_dir: str, project_name: str) -> bool:
|
|||||||
"docker", "compose",
|
"docker", "compose",
|
||||||
"-f", compose_file,
|
"-f", compose_file,
|
||||||
"-p", project_name,
|
"-p", project_name,
|
||||||
"up", "-d", "--remove-orphans",
|
"up", "-d",
|
||||||
]
|
]
|
||||||
|
if not services:
|
||||||
|
cmd.append("--remove-orphans")
|
||||||
|
if services:
|
||||||
|
cmd.extend(services)
|
||||||
|
|
||||||
logger.info("Running: %s", " ".join(cmd))
|
logger.info("Running: %s", " ".join(cmd))
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("docker compose up failed: %s", result.stderr)
|
logger.error("docker compose up failed: %s", result.stderr)
|
||||||
raise RuntimeError(f"docker compose up failed: {result.stderr}")
|
raise RuntimeError(f"docker compose up failed: {result.stderr}")
|
||||||
|
|
||||||
logger.info("docker compose up succeeded for %s", project_name)
|
svc_info = f" (services: {', '.join(services)})" if services else ""
|
||||||
|
logger.info("docker compose up succeeded for %s%s", project_name, svc_info)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -169,9 +183,13 @@ def get_container_status(container_prefix: str) -> list[dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
containers = client.containers.list(all=True, filters={"name": container_prefix})
|
containers = client.containers.list(all=True, filters={"name": container_prefix})
|
||||||
for c in containers:
|
for c in containers:
|
||||||
health = "N/A"
|
# Derive health from container status.
|
||||||
if c.attrs.get("State", {}).get("Health"):
|
# Docker HEALTHCHECK is unreliable (e.g. netbirdio/management
|
||||||
health = c.attrs["State"]["Health"].get("Status", "N/A")
|
# defines a wget-based check but wget is not installed).
|
||||||
|
if c.status == "running":
|
||||||
|
health = "healthy"
|
||||||
|
else:
|
||||||
|
health = "unhealthy"
|
||||||
results.append({
|
results.append({
|
||||||
"name": c.name,
|
"name": c.name,
|
||||||
"status": c.status,
|
"status": c.status,
|
||||||
|
|||||||
@@ -6,26 +6,33 @@ Coordinates the full customer deployment lifecycle:
|
|||||||
3. Generate configs from Jinja2 templates
|
3. Generate configs from Jinja2 templates
|
||||||
4. Create instance directory and write files
|
4. Create instance directory and write files
|
||||||
5. Start Docker containers
|
5. Start Docker containers
|
||||||
6. Wait for health checks
|
6. Create NPM proxy hosts (production only)
|
||||||
7. Create NPM proxy hosts
|
7. Update database
|
||||||
8. Update database
|
|
||||||
|
Uses NetBird's embedded IdP (built-in since v0.62) — no external
|
||||||
|
identity provider (Zitadel, Keycloak, etc.) required.
|
||||||
|
|
||||||
Includes comprehensive rollback on failure.
|
Includes comprehensive rollback on failure.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Customer, Deployment, DeploymentLog, SystemConfig
|
from app.models import Customer, Deployment, DeploymentLog
|
||||||
from app.services import docker_service, npm_service, port_manager
|
from app.services import docker_service, npm_service, port_manager
|
||||||
from app.utils.config import get_system_config
|
from app.utils.config import get_system_config
|
||||||
from app.utils.security import encrypt_value, generate_relay_secret
|
from app.utils.security import encrypt_value, generate_datastore_encryption_key, generate_relay_secret
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,19 +48,16 @@ def _get_jinja_env() -> Environment:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_domain(base_domain: str) -> bool:
|
||||||
|
"""Check if the base domain is a local/test domain."""
|
||||||
|
local_suffixes = (".local", ".test", ".localhost", ".internal", ".example")
|
||||||
|
return base_domain == "localhost" or any(base_domain.endswith(s) for s in local_suffixes)
|
||||||
|
|
||||||
|
|
||||||
def _log_action(
|
def _log_action(
|
||||||
db: Session, customer_id: int, action: str, status: str, message: str, details: str = ""
|
db: Session, customer_id: int, action: str, status: str, message: str, details: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write a deployment log entry.
|
"""Write a deployment log entry."""
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Active session.
|
|
||||||
customer_id: The customer this log belongs to.
|
|
||||||
action: Action name (e.g. ``deploy``, ``stop``).
|
|
||||||
status: ``success``, ``error``, or ``info``.
|
|
||||||
message: Human-readable message.
|
|
||||||
details: Additional details (optional).
|
|
||||||
"""
|
|
||||||
log = DeploymentLog(
|
log = DeploymentLog(
|
||||||
customer_id=customer_id,
|
customer_id=customer_id,
|
||||||
action=action,
|
action=action,
|
||||||
@@ -65,15 +69,20 @@ def _log_action(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_template(jinja_env: Environment, template_name: str, output_path: str, **vars) -> None:
|
||||||
|
"""Render a Jinja2 template and write the output to a file."""
|
||||||
|
template = jinja_env.get_template(template_name)
|
||||||
|
content = template.render(**vars)
|
||||||
|
with open(output_path, "w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Execute the full deployment workflow for a customer.
|
"""Execute the full deployment workflow for a customer.
|
||||||
|
|
||||||
Args:
|
Uses NetBird's embedded IdP — no external identity provider needed.
|
||||||
db: Active session.
|
After deployment, the admin opens the dashboard URL and completes
|
||||||
customer_id: Customer to deploy.
|
the initial setup wizard (/setup) to create the first user.
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ``success``, ``setup_url``, or ``error``.
|
|
||||||
"""
|
"""
|
||||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
if not customer:
|
if not customer:
|
||||||
@@ -83,7 +92,6 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
if not config:
|
if not config:
|
||||||
return {"success": False, "error": "System not configured. Please set up system settings first."}
|
return {"success": False, "error": "System not configured. Please set up system settings first."}
|
||||||
|
|
||||||
# Update status to deploying
|
|
||||||
customer.status = "deploying"
|
customer.status = "deploying"
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -92,103 +100,161 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
allocated_port = None
|
allocated_port = None
|
||||||
instance_dir = None
|
instance_dir = None
|
||||||
container_prefix = f"netbird-kunde{customer_id}"
|
container_prefix = f"netbird-kunde{customer_id}"
|
||||||
|
local_mode = _is_local_domain(config.base_domain)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Allocate relay UDP port
|
# Step 1: Allocate relay UDP port
|
||||||
allocated_port = port_manager.allocate_port(db, config.relay_base_port)
|
allocated_port = port_manager.allocate_port(db, config.relay_base_port)
|
||||||
_log_action(db, customer_id, "deploy", "info", f"Allocated UDP port {allocated_port}.")
|
_log_action(db, customer_id, "deploy", "info", f"Allocated UDP port {allocated_port}.")
|
||||||
|
|
||||||
# Step 2: Generate relay secret
|
# Step 2: Generate secrets
|
||||||
relay_secret = generate_relay_secret()
|
relay_secret = generate_relay_secret()
|
||||||
|
datastore_key = generate_datastore_encryption_key()
|
||||||
|
|
||||||
# Step 3: Create instance directory
|
# Step 3: Compute dashboard port and URLs
|
||||||
|
dashboard_port = config.dashboard_base_port + customer_id
|
||||||
|
netbird_domain = f"{customer.subdomain}.{config.base_domain}"
|
||||||
|
|
||||||
|
if local_mode:
|
||||||
|
external_url = f"http://localhost:{dashboard_port}"
|
||||||
|
netbird_protocol = "http"
|
||||||
|
netbird_port = str(dashboard_port)
|
||||||
|
else:
|
||||||
|
external_url = f"https://{netbird_domain}"
|
||||||
|
netbird_protocol = "https"
|
||||||
|
netbird_port = "443"
|
||||||
|
|
||||||
|
# Step 4: Create instance directory
|
||||||
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
|
instance_dir = os.path.join(config.data_dir, f"kunde{customer_id}")
|
||||||
os.makedirs(instance_dir, exist_ok=True)
|
os.makedirs(instance_dir, exist_ok=True)
|
||||||
os.makedirs(os.path.join(instance_dir, "data", "management"), exist_ok=True)
|
os.makedirs(os.path.join(instance_dir, "data", "management"), exist_ok=True)
|
||||||
os.makedirs(os.path.join(instance_dir, "data", "signal"), exist_ok=True)
|
os.makedirs(os.path.join(instance_dir, "data", "signal"), exist_ok=True)
|
||||||
_log_action(db, customer_id, "deploy", "info", f"Created directory {instance_dir}.")
|
_log_action(db, customer_id, "deploy", "info", f"Created directory {instance_dir}.")
|
||||||
|
|
||||||
# Step 4: Render templates
|
# Step 5: Render all config files
|
||||||
jinja_env = _get_jinja_env()
|
jinja_env = _get_jinja_env()
|
||||||
template_vars = {
|
template_vars = {
|
||||||
"customer_id": customer_id,
|
"customer_id": customer_id,
|
||||||
"subdomain": customer.subdomain,
|
"subdomain": customer.subdomain,
|
||||||
"base_domain": config.base_domain,
|
"base_domain": config.base_domain,
|
||||||
|
"netbird_domain": netbird_domain,
|
||||||
"instance_dir": instance_dir,
|
"instance_dir": instance_dir,
|
||||||
"relay_udp_port": allocated_port,
|
"relay_udp_port": allocated_port,
|
||||||
"relay_secret": relay_secret,
|
"relay_secret": relay_secret,
|
||||||
|
"dashboard_port": dashboard_port,
|
||||||
|
"external_url": external_url,
|
||||||
|
"netbird_protocol": netbird_protocol,
|
||||||
|
"netbird_port": netbird_port,
|
||||||
"netbird_management_image": config.netbird_management_image,
|
"netbird_management_image": config.netbird_management_image,
|
||||||
"netbird_signal_image": config.netbird_signal_image,
|
"netbird_signal_image": config.netbird_signal_image,
|
||||||
"netbird_relay_image": config.netbird_relay_image,
|
"netbird_relay_image": config.netbird_relay_image,
|
||||||
"netbird_dashboard_image": config.netbird_dashboard_image,
|
"netbird_dashboard_image": config.netbird_dashboard_image,
|
||||||
"docker_network": config.docker_network,
|
"docker_network": config.docker_network,
|
||||||
|
"datastore_encryption_key": datastore_key,
|
||||||
}
|
}
|
||||||
|
|
||||||
# docker-compose.yml
|
_render_template(jinja_env, "docker-compose.yml.j2",
|
||||||
dc_template = jinja_env.get_template("docker-compose.yml.j2")
|
os.path.join(instance_dir, "docker-compose.yml"), **template_vars)
|
||||||
dc_content = dc_template.render(**template_vars)
|
_render_template(jinja_env, "management.json.j2",
|
||||||
with open(os.path.join(instance_dir, "docker-compose.yml"), "w") as f:
|
os.path.join(instance_dir, "management.json"), **template_vars)
|
||||||
f.write(dc_content)
|
_render_template(jinja_env, "relay.env.j2",
|
||||||
|
os.path.join(instance_dir, "relay.env"), **template_vars)
|
||||||
# management.json
|
_render_template(jinja_env, "Caddyfile.j2",
|
||||||
mgmt_template = jinja_env.get_template("management.json.j2")
|
os.path.join(instance_dir, "Caddyfile"), **template_vars)
|
||||||
mgmt_content = mgmt_template.render(**template_vars)
|
_render_template(jinja_env, "dashboard.env.j2",
|
||||||
with open(os.path.join(instance_dir, "management.json"), "w") as f:
|
os.path.join(instance_dir, "dashboard.env"), **template_vars)
|
||||||
f.write(mgmt_content)
|
|
||||||
|
|
||||||
# relay.env
|
|
||||||
relay_template = jinja_env.get_template("relay.env.j2")
|
|
||||||
relay_content = relay_template.render(**template_vars)
|
|
||||||
with open(os.path.join(instance_dir, "relay.env"), "w") as f:
|
|
||||||
f.write(relay_content)
|
|
||||||
|
|
||||||
_log_action(db, customer_id, "deploy", "info", "Configuration files generated.")
|
_log_action(db, customer_id, "deploy", "info", "Configuration files generated.")
|
||||||
|
|
||||||
# Step 5: Start Docker containers
|
# Step 6: Start all Docker containers
|
||||||
docker_service.compose_up(instance_dir, container_prefix)
|
docker_service.compose_up(instance_dir, container_prefix, timeout=120)
|
||||||
_log_action(db, customer_id, "deploy", "info", "Docker containers started.")
|
_log_action(db, customer_id, "deploy", "info", "Docker containers started.")
|
||||||
|
|
||||||
# Step 6: Wait for containers to be healthy
|
# Step 7: Wait for containers to be healthy
|
||||||
healthy = docker_service.wait_for_healthy(container_prefix, timeout=60)
|
healthy = docker_service.wait_for_healthy(container_prefix, timeout=90)
|
||||||
if not healthy:
|
if not healthy:
|
||||||
_log_action(
|
_log_action(
|
||||||
db, customer_id, "deploy", "error",
|
db, customer_id, "deploy", "info",
|
||||||
"Containers did not become healthy within 60 seconds."
|
"Not all containers healthy within 90s — may still be starting."
|
||||||
)
|
)
|
||||||
# Don't fail completely — containers might still come up
|
|
||||||
|
|
||||||
# Step 7: Create NPM proxy host
|
# Step 8: Auto-create admin user via NetBird setup API
|
||||||
domain = f"{customer.subdomain}.{config.base_domain}"
|
admin_email = customer.email
|
||||||
dashboard_container = f"netbird-kunde{customer_id}-dashboard"
|
admin_password = secrets.token_urlsafe(16)
|
||||||
|
management_container = f"netbird-kunde{customer_id}-management"
|
||||||
|
setup_api_url = f"http://{management_container}:80/api/setup"
|
||||||
|
setup_payload = json.dumps({
|
||||||
|
"name": customer.name,
|
||||||
|
"email": admin_email,
|
||||||
|
"password": admin_password,
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
setup_ok = False
|
||||||
|
for attempt in range(10):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
setup_api_url,
|
||||||
|
data=setup_payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
if resp.status in (200, 201):
|
||||||
|
setup_ok = True
|
||||||
|
_log_action(db, customer_id, "deploy", "info",
|
||||||
|
f"Admin user created: {admin_email}")
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", errors="replace")
|
||||||
|
if e.code == 409 or "already" in body.lower():
|
||||||
|
_log_action(db, customer_id, "deploy", "info",
|
||||||
|
"Instance already set up — skipping admin creation.")
|
||||||
|
setup_ok = True
|
||||||
|
break
|
||||||
|
logger.info("Setup attempt %d failed (HTTP %d): %s", attempt + 1, e.code, body)
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Setup attempt %d failed: %s", attempt + 1, e)
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
if not setup_ok:
|
||||||
|
_log_action(db, customer_id, "deploy", "info",
|
||||||
|
"Auto-setup failed — admin must complete setup manually.")
|
||||||
|
|
||||||
|
# Step 9: Create NPM proxy host (production only)
|
||||||
|
npm_proxy_id = None
|
||||||
|
if not local_mode:
|
||||||
|
caddy_container = f"netbird-kunde{customer_id}-caddy"
|
||||||
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,
|
||||||
npm_email=config.npm_api_email,
|
npm_email=config.npm_api_email,
|
||||||
npm_password=config.npm_api_password,
|
npm_password=config.npm_api_password,
|
||||||
domain=domain,
|
domain=netbird_domain,
|
||||||
forward_host=dashboard_container,
|
forward_host=caddy_container,
|
||||||
forward_port=80,
|
forward_port=80,
|
||||||
admin_email=config.admin_email,
|
admin_email=config.admin_email,
|
||||||
subdomain=customer.subdomain,
|
subdomain=customer.subdomain,
|
||||||
customer_id=customer_id,
|
customer_id=customer_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
npm_proxy_id = npm_result.get("proxy_id")
|
npm_proxy_id = npm_result.get("proxy_id")
|
||||||
if npm_result.get("error"):
|
if npm_result.get("error"):
|
||||||
_log_action(
|
_log_action(
|
||||||
db, customer_id, "deploy", "error",
|
db, customer_id, "deploy", "error",
|
||||||
f"NPM proxy creation failed: {npm_result['error']}",
|
f"NPM proxy creation failed: {npm_result['error']}",
|
||||||
)
|
)
|
||||||
# Continue — deployment works without NPM, admin can fix later
|
|
||||||
|
|
||||||
# Step 8: Create deployment record
|
# Step 9: Create deployment record
|
||||||
setup_url = f"https://{domain}"
|
setup_url = external_url
|
||||||
|
|
||||||
deployment = Deployment(
|
deployment = Deployment(
|
||||||
customer_id=customer_id,
|
customer_id=customer_id,
|
||||||
container_prefix=container_prefix,
|
container_prefix=container_prefix,
|
||||||
relay_udp_port=allocated_port,
|
relay_udp_port=allocated_port,
|
||||||
|
dashboard_port=dashboard_port,
|
||||||
npm_proxy_id=npm_proxy_id,
|
npm_proxy_id=npm_proxy_id,
|
||||||
relay_secret=encrypt_value(relay_secret),
|
relay_secret=encrypt_value(relay_secret),
|
||||||
setup_url=setup_url,
|
setup_url=setup_url,
|
||||||
|
netbird_admin_email=encrypt_value(admin_email) if setup_ok else None,
|
||||||
|
netbird_admin_password=encrypt_value(admin_password) if setup_ok else None,
|
||||||
deployment_status="running",
|
deployment_status="running",
|
||||||
deployed_at=datetime.utcnow(),
|
deployed_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
@@ -197,7 +263,8 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
customer.status = "active"
|
customer.status = "active"
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
_log_action(db, customer_id, "deploy", "success", f"Deployment complete. URL: {setup_url}")
|
_log_action(db, customer_id, "deploy", "success",
|
||||||
|
f"Deployment complete. Open {setup_url} to complete initial setup.")
|
||||||
|
|
||||||
return {"success": True, "setup_url": setup_url}
|
return {"success": True, "setup_url": setup_url}
|
||||||
|
|
||||||
@@ -234,15 +301,7 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Remove all resources for a customer deployment.
|
"""Remove all resources for a customer deployment."""
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Active session.
|
|
||||||
customer_id: Customer to undeploy.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ``success`` bool.
|
|
||||||
"""
|
|
||||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
if not customer:
|
if not customer:
|
||||||
return {"success": False, "error": "Customer not found."}
|
return {"success": False, "error": "Customer not found."}
|
||||||
@@ -288,15 +347,7 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Stop containers for a customer.
|
"""Stop containers for a customer."""
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Active session.
|
|
||||||
customer_id: Customer whose containers to stop.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ``success`` bool.
|
|
||||||
"""
|
|
||||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
deployment = db.query(Deployment).filter(Deployment.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:
|
||||||
@@ -306,6 +357,9 @@ def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
ok = docker_service.compose_stop(instance_dir, deployment.container_prefix)
|
ok = 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()
|
||||||
|
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:
|
||||||
@@ -314,15 +368,7 @@ def stop_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Start containers for a customer.
|
"""Start containers for a customer."""
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Active session.
|
|
||||||
customer_id: Customer whose containers to start.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ``success`` bool.
|
|
||||||
"""
|
|
||||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
deployment = db.query(Deployment).filter(Deployment.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:
|
||||||
@@ -332,6 +378,9 @@ def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
ok = docker_service.compose_start(instance_dir, deployment.container_prefix)
|
ok = 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()
|
||||||
|
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:
|
||||||
@@ -340,15 +389,7 @@ def start_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Restart containers for a customer.
|
"""Restart containers for a customer."""
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Active session.
|
|
||||||
customer_id: Customer whose containers to restart.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with ``success`` bool.
|
|
||||||
"""
|
|
||||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
deployment = db.query(Deployment).filter(Deployment.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:
|
||||||
@@ -358,6 +399,9 @@ def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
ok = docker_service.compose_restart(instance_dir, deployment.container_prefix)
|
ok = 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()
|
||||||
|
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:
|
||||||
@@ -366,15 +410,7 @@ def restart_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def get_customer_health(db: Session, customer_id: int) -> dict[str, Any]:
|
def get_customer_health(db: Session, customer_id: int) -> dict[str, Any]:
|
||||||
"""Check health of a customer's deployment.
|
"""Check health of a customer's deployment."""
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Active session.
|
|
||||||
customer_id: Customer ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with container statuses and overall health.
|
|
||||||
"""
|
|
||||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||||
if not deployment:
|
if not deployment:
|
||||||
return {"healthy": False, "error": "No deployment found.", "containers": []}
|
return {"healthy": False, "error": "No deployment found.", "containers": []}
|
||||||
@@ -382,12 +418,16 @@ def get_customer_health(db: Session, customer_id: int) -> dict[str, Any]:
|
|||||||
containers = docker_service.get_container_status(deployment.container_prefix)
|
containers = docker_service.get_container_status(deployment.container_prefix)
|
||||||
all_running = all(c["status"] == "running" for c in containers) if containers else False
|
all_running = all(c["status"] == "running" for c in containers) if containers else False
|
||||||
|
|
||||||
# Update last health check time
|
|
||||||
deployment.last_health_check = datetime.utcnow()
|
deployment.last_health_check = datetime.utcnow()
|
||||||
|
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||||
if all_running:
|
if all_running:
|
||||||
deployment.deployment_status = "running"
|
deployment.deployment_status = "running"
|
||||||
|
if customer:
|
||||||
|
customer.status = "active"
|
||||||
elif containers:
|
elif containers:
|
||||||
deployment.deployment_status = "failed"
|
deployment.deployment_status = "failed"
|
||||||
|
if customer:
|
||||||
|
customer.status = "error"
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AppConfig:
|
|||||||
data_dir: str
|
data_dir: str
|
||||||
docker_network: str
|
docker_network: str
|
||||||
relay_base_port: int
|
relay_base_port: int
|
||||||
|
dashboard_base_port: int
|
||||||
|
|
||||||
|
|
||||||
# Environment-level settings (not stored in DB)
|
# Environment-level settings (not stored in DB)
|
||||||
@@ -77,4 +78,5 @@ def get_system_config(db: Session) -> Optional[AppConfig]:
|
|||||||
data_dir=row.data_dir,
|
data_dir=row.data_dir,
|
||||||
docker_network=row.docker_network,
|
docker_network=row.docker_network,
|
||||||
relay_base_port=row.relay_base_port,
|
relay_base_port=row.relay_base_port,
|
||||||
|
dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,3 +89,16 @@ def generate_relay_secret() -> str:
|
|||||||
A 32-character hex string.
|
A 32-character hex string.
|
||||||
"""
|
"""
|
||||||
return secrets.token_hex(16)
|
return secrets.token_hex(16)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_datastore_encryption_key() -> str:
|
||||||
|
"""Generate a base64-encoded 32-byte key for NetBird DataStoreEncryptionKey.
|
||||||
|
|
||||||
|
NetBird management (Go) expects standard base64 decoding to exactly 32 bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A standard base64-encoded string representing 32 random bytes.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
return base64.b64encode(secrets.token_bytes(32)).decode()
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ class SystemConfigUpdate(BaseModel):
|
|||||||
data_dir: Optional[str] = Field(None, max_length=500)
|
data_dir: Optional[str] = Field(None, max_length=500)
|
||||||
docker_network: Optional[str] = Field(None, max_length=100)
|
docker_network: Optional[str] = Field(None, max_length=100)
|
||||||
relay_base_port: Optional[int] = Field(None, ge=1024, le=65535)
|
relay_base_port: Optional[int] = Field(None, ge=1024, le=65535)
|
||||||
|
dashboard_base_port: Optional[int] = Field(None, ge=1024, le=65535)
|
||||||
|
branding_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
branding_subtitle: Optional[str] = Field(None, max_length=255)
|
||||||
|
default_language: Optional[str] = Field(None, max_length=10)
|
||||||
|
azure_enabled: Optional[bool] = None
|
||||||
|
azure_tenant_id: Optional[str] = Field(None, max_length=255)
|
||||||
|
azure_client_id: Optional[str] = Field(None, max_length=255)
|
||||||
|
azure_client_secret: Optional[str] = None # encrypted before storage
|
||||||
|
|
||||||
@field_validator("base_domain")
|
@field_validator("base_domain")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -143,6 +151,54 @@ class SystemConfigUpdate(BaseModel):
|
|||||||
return v.lower().strip()
|
return v.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Users
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""Payload to create a new local user."""
|
||||||
|
|
||||||
|
username: str = Field(..., min_length=3, max_length=100)
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
default_language: Optional[str] = Field(None, max_length=10)
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def validate_username(cls, v: str) -> str:
|
||||||
|
if not re.match(r"^[a-zA-Z0-9_.-]+$", v):
|
||||||
|
raise ValueError("Username may only contain letters, digits, dots, hyphens, and underscores.")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def validate_user_email(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||||
|
if not re.match(pattern, v):
|
||||||
|
raise ValueError("Invalid email address.")
|
||||||
|
return v.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""Payload to update an existing user."""
|
||||||
|
|
||||||
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
role: Optional[str] = Field(None, max_length=20)
|
||||||
|
default_language: Optional[str] = Field(None, max_length=10)
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def validate_user_email(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||||
|
if not re.match(pattern, v):
|
||||||
|
raise ValueError("Invalid email address.")
|
||||||
|
return v.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Query params
|
# Query params
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Binary file not shown.
@@ -13,9 +13,11 @@ python-multipart==0.0.6
|
|||||||
httpx==0.26.0
|
httpx==0.26.0
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
docker==7.0.0
|
docker==7.0.0
|
||||||
|
requests<2.32.0
|
||||||
urllib3<2
|
urllib3<2
|
||||||
psutil==5.9.7
|
psutil==5.9.7
|
||||||
pyyaml==6.0.1
|
pyyaml==6.0.1
|
||||||
|
msal==1.28.0
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
pytest-asyncio==0.23.2
|
pytest-asyncio==0.23.2
|
||||||
pytest-httpx==0.28.0
|
pytest-httpx==0.28.0
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/* NetBird MSP Appliance - Custom Styles */
|
/* NetBird MSP Appliance - Custom Styles */
|
||||||
|
|
||||||
|
/* i18n FOUC prevention */
|
||||||
|
body.i18n-loading #login-page,
|
||||||
|
body.i18n-loading #app-page {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* Login */
|
/* Login */
|
||||||
.login-container {
|
.login-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -163,6 +169,15 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-brand img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login logo */
|
||||||
|
#login-logo img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* Card improvements */
|
/* Card improvements */
|
||||||
.card {
|
.card {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -8,32 +8,38 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
<link href="/static/css/styles.css" rel="stylesheet">
|
<link href="/static/css/styles.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="i18n-loading">
|
||||||
<!-- Login Page -->
|
<!-- Login Page -->
|
||||||
<div id="login-page" class="d-none">
|
<div id="login-page" class="d-none">
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<div class="card login-card shadow">
|
<div class="card login-card shadow">
|
||||||
<div class="card-body p-5">
|
<div class="card-body p-5">
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<i class="bi bi-hdd-network fs-1 text-primary"></i>
|
<div id="login-logo"><i class="bi bi-hdd-network fs-1 text-primary"></i></div>
|
||||||
<h3 class="mt-2">NetBird MSP Appliance</h3>
|
<h3 class="mt-2" id="login-title">NetBird MSP Appliance</h3>
|
||||||
<p class="text-muted">Multi-Tenant Management Platform</p>
|
<p class="text-muted" id="login-subtitle" data-i18n="login.subtitle">Multi-Tenant Management Platform</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="login-error" class="alert alert-danger d-none"></div>
|
<div id="login-error" class="alert alert-danger d-none"></div>
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Username</label>
|
<label class="form-label" data-i18n="login.username">Username</label>
|
||||||
<input type="text" class="form-control" id="login-username" required autofocus>
|
<input type="text" class="form-control" id="login-username" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Password</label>
|
<label class="form-label" data-i18n="login.password">Password</label>
|
||||||
<input type="password" class="form-control" id="login-password" required>
|
<input type="password" class="form-control" id="login-password" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary w-100" id="login-btn">
|
<button type="submit" class="btn btn-primary w-100" id="login-btn">
|
||||||
<span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
|
<span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
|
||||||
Sign In
|
<span data-i18n="login.signIn">Sign In</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div id="azure-login-divider" class="d-none">
|
||||||
|
<hr class="my-3">
|
||||||
|
<button type="button" class="btn btn-outline-dark w-100" id="azure-login-btn" onclick="loginWithAzure()">
|
||||||
|
<i class="bi bi-microsoft me-2"></i><span data-i18n="login.signInWithMicrosoft">Sign in with Microsoft</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,20 +50,31 @@
|
|||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="#"><i class="bi bi-hdd-network me-2"></i>NetBird MSP</a>
|
<a class="navbar-brand d-flex align-items-center" href="#" onclick="showPage('dashboard'); return false;">
|
||||||
|
<span id="nav-logo"><i class="bi bi-hdd-network me-2"></i></span>
|
||||||
|
<span id="nav-brand-name">NetBird MSP</span>
|
||||||
|
</a>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
|
<!-- Language Switcher -->
|
||||||
|
<div class="dropdown me-2">
|
||||||
|
<button class="btn btn-outline-light btn-sm dropdown-toggle" id="language-switcher-btn" data-bs-toggle="dropdown" aria-expanded="false">EN</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item" href="#" data-lang="en" onclick="switchLanguage('en'); return false;">English</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#" data-lang="de" onclick="switchLanguage('de'); return false;">Deutsch</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<button class="btn btn-success btn-sm me-3" onclick="showNewCustomerModal()">
|
<button class="btn btn-success btn-sm me-3" onclick="showNewCustomerModal()">
|
||||||
<i class="bi bi-plus-lg me-1"></i>New Customer
|
<i class="bi bi-plus-lg me-1"></i><span data-i18n="nav.newCustomer">New Customer</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-outline-light btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
<button class="btn btn-outline-light btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
<i class="bi bi-person-circle me-1"></i><span id="nav-username">Admin</span>
|
<i class="bi bi-person-circle me-1"></i><span id="nav-username">Admin</span>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li><a class="dropdown-item" href="#" onclick="showPage('settings')"><i class="bi bi-gear me-2"></i>Settings</a></li>
|
<li><a class="dropdown-item" href="#" onclick="showPage('settings')"><i class="bi bi-gear me-2"></i><span data-i18n="nav.settings">Settings</span></a></li>
|
||||||
<li><a class="dropdown-item" href="#" onclick="showPage('monitoring')"><i class="bi bi-activity me-2"></i>Monitoring</a></li>
|
<li><a class="dropdown-item" href="#" onclick="showPage('monitoring')"><i class="bi bi-activity me-2"></i><span data-i18n="nav.monitoring">Monitoring</span></a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item text-danger" href="#" onclick="logout()"><i class="bi bi-box-arrow-right me-2"></i>Logout</a></li>
|
<li><a class="dropdown-item text-danger" href="#" onclick="logout()"><i class="bi bi-box-arrow-right me-2"></i><span data-i18n="nav.logout">Logout</span></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +91,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small">Total Customers</div>
|
<div class="text-muted small" data-i18n="dashboard.totalCustomers">Total Customers</div>
|
||||||
<div class="fs-3 fw-bold" id="stat-total">0</div>
|
<div class="fs-3 fw-bold" id="stat-total">0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-people"></i></div>
|
<div class="stat-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-people"></i></div>
|
||||||
@@ -87,7 +104,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small">Active</div>
|
<div class="text-muted small" data-i18n="dashboard.active">Active</div>
|
||||||
<div class="fs-3 fw-bold text-success" id="stat-active">0</div>
|
<div class="fs-3 fw-bold text-success" id="stat-active">0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon bg-success bg-opacity-10 text-success"><i class="bi bi-check-circle"></i></div>
|
<div class="stat-icon bg-success bg-opacity-10 text-success"><i class="bi bi-check-circle"></i></div>
|
||||||
@@ -100,7 +117,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small">Inactive</div>
|
<div class="text-muted small" data-i18n="dashboard.inactive">Inactive</div>
|
||||||
<div class="fs-3 fw-bold text-warning" id="stat-inactive">0</div>
|
<div class="fs-3 fw-bold text-warning" id="stat-inactive">0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-pause-circle"></i></div>
|
<div class="stat-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-pause-circle"></i></div>
|
||||||
@@ -113,7 +130,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small">Errors</div>
|
<div class="text-muted small" data-i18n="dashboard.errors">Errors</div>
|
||||||
<div class="fs-3 fw-bold text-danger" id="stat-error">0</div>
|
<div class="fs-3 fw-bold text-danger" id="stat-error">0</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon bg-danger bg-opacity-10 text-danger"><i class="bi bi-exclamation-triangle"></i></div>
|
<div class="stat-icon bg-danger bg-opacity-10 text-danger"><i class="bi bi-exclamation-triangle"></i></div>
|
||||||
@@ -128,19 +145,19 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<input type="text" class="form-control" id="search-input" placeholder="Search by name, subdomain, email...">
|
<input type="text" class="form-control" id="search-input" data-i18n-placeholder="dashboard.searchPlaceholder" placeholder="Search by name, subdomain, email...">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<select class="form-select" id="status-filter">
|
<select class="form-select" id="status-filter">
|
||||||
<option value="">All Statuses</option>
|
<option value="" data-i18n="dashboard.allStatuses">All Statuses</option>
|
||||||
<option value="active">Active</option>
|
<option value="active" data-i18n="dashboard.statusActive">Active</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="inactive" data-i18n="dashboard.statusInactive">Inactive</option>
|
||||||
<option value="deploying">Deploying</option>
|
<option value="deploying" data-i18n="dashboard.statusDeploying">Deploying</option>
|
||||||
<option value="error">Error</option>
|
<option value="error" data-i18n="dashboard.statusError">Error</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 text-end">
|
<div class="col-md-3 text-end">
|
||||||
<button class="btn btn-outline-secondary" onclick="loadCustomers()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
|
<button class="btn btn-outline-secondary" onclick="loadCustomers()"><i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="dashboard.refresh">Refresh</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,24 +169,24 @@
|
|||||||
<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>ID</th>
|
<th data-i18n="dashboard.thId">ID</th>
|
||||||
<th>Name</th>
|
<th data-i18n="dashboard.thName">Name</th>
|
||||||
<th>Company</th>
|
<th data-i18n="dashboard.thSubdomain">Subdomain</th>
|
||||||
<th>Subdomain</th>
|
<th data-i18n="dashboard.thStatus">Status</th>
|
||||||
<th>Status</th>
|
<th data-i18n="dashboard.thDashboard">Dashboard</th>
|
||||||
<th>Devices</th>
|
<th data-i18n="dashboard.thDevices">Devices</th>
|
||||||
<th>Created</th>
|
<th data-i18n="dashboard.thCreated">Created</th>
|
||||||
<th>Actions</th>
|
<th data-i18n="dashboard.thActions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="customers-table-body">
|
<tbody id="customers-table-body">
|
||||||
<tr><td colspan="8" class="text-center text-muted py-4">Loading...</td></tr>
|
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||||
<div class="text-muted small" id="pagination-info">Showing 0 of 0</div>
|
<div class="text-muted small" id="pagination-info" data-i18n="dashboard.showingEmpty">Showing 0 of 0</div>
|
||||||
<nav>
|
<nav>
|
||||||
<ul class="pagination pagination-sm mb-0" id="pagination-controls"></ul>
|
<ul class="pagination pagination-sm mb-0" id="pagination-controls"></ul>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -183,45 +200,45 @@
|
|||||||
<div class="container-fluid p-4">
|
<div class="container-fluid p-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i>Back</button>
|
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
|
||||||
<span class="fs-4 fw-bold" id="detail-customer-name">Customer</span>
|
<span class="fs-4 fw-bold" id="detail-customer-name">Customer</span>
|
||||||
<span class="badge ms-2" id="detail-customer-status">active</span>
|
<span class="badge ms-2" id="detail-customer-status">active</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-outline-primary btn-sm me-1" onclick="editCurrentCustomer()"><i class="bi bi-pencil me-1"></i>Edit</button>
|
<button class="btn btn-outline-primary btn-sm me-1" onclick="editCurrentCustomer()"><i class="bi bi-pencil me-1"></i><span data-i18n="customer.edit">Edit</span></button>
|
||||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteCurrentCustomer()"><i class="bi bi-trash me-1"></i>Delete</button>
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteCurrentCustomer()"><i class="bi bi-trash me-1"></i><span data-i18n="customer.delete">Delete</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="nav nav-tabs mb-3" id="detail-tabs">
|
<ul class="nav nav-tabs mb-3" id="detail-tabs">
|
||||||
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-info">Info</a></li>
|
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-info" data-i18n="customer.tabInfo">Info</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deployment">Deployment</a></li>
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deployment" data-i18n="customer.tabDeployment">Deployment</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-logs">Logs</a></li>
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-logs" data-i18n="customer.tabLogs">Logs</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-health">Health</a></li>
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-health" data-i18n="customer.tabHealth">Health</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<!-- Tab: Info -->
|
<!-- Tab: Info -->
|
||||||
<div class="tab-pane fade show active" id="tab-info">
|
<div class="tab-pane fade show active" id="tab-info">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body" id="detail-info-content">Loading...</div>
|
<div class="card-body" id="detail-info-content" data-i18n="common.loading">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab: Deployment -->
|
<!-- Tab: Deployment -->
|
||||||
<div class="tab-pane fade" id="tab-deployment">
|
<div class="tab-pane fade" id="tab-deployment">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body" id="detail-deployment-content">Loading...</div>
|
<div class="card-body" id="detail-deployment-content" data-i18n="common.loading">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tab: Logs -->
|
<!-- Tab: Logs -->
|
||||||
<div class="tab-pane fade" id="tab-logs">
|
<div class="tab-pane fade" id="tab-logs">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header d-flex justify-content-between">
|
<div class="card-header d-flex justify-content-between">
|
||||||
<span>Container Logs</span>
|
<span data-i18n="customer.containerLogs">Container Logs</span>
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerLogs()"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerLogs()"><i class="bi bi-arrow-clockwise"></i> <span data-i18n="dashboard.refresh">Refresh</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="detail-logs-content" class="log-viewer">No logs loaded.</div>
|
<div id="detail-logs-content" class="log-viewer" data-i18n="customer.noLogsLoaded">No logs loaded.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,10 +246,10 @@
|
|||||||
<div class="tab-pane fade" id="tab-health">
|
<div class="tab-pane fade" id="tab-health">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header d-flex justify-content-between">
|
<div class="card-header d-flex justify-content-between">
|
||||||
<span>Health Check</span>
|
<span data-i18n="customer.healthCheck">Health Check</span>
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerHealth()"><i class="bi bi-arrow-clockwise"></i> Check</button>
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerHealth()"><i class="bi bi-arrow-clockwise"></i> <span data-i18n="customer.check">Check</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" id="detail-health-content">Click "Check" to run a health check.</div>
|
<div class="card-body" id="detail-health-content" data-i18n="customer.clickCheck">Click "Check" to run a health check.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,14 +259,22 @@
|
|||||||
<!-- Page: Settings -->
|
<!-- Page: Settings -->
|
||||||
<div id="page-settings" class="page-content d-none">
|
<div id="page-settings" class="page-content d-none">
|
||||||
<div class="container-fluid p-4">
|
<div class="container-fluid p-4">
|
||||||
<h4 class="mb-4"><i class="bi bi-gear me-2"></i>System Settings</h4>
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
|
||||||
|
<i class="bi bi-gear me-2"></i><span data-i18n="settings.title">System Settings</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
<div id="settings-alert" class="d-none"></div>
|
<div id="settings-alert" class="d-none"></div>
|
||||||
|
|
||||||
<ul class="nav nav-tabs mb-3">
|
<ul class="nav nav-tabs mb-3">
|
||||||
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#settings-system">System Configuration</a></li>
|
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#settings-system" data-i18n="settings.tabSystem">System Configuration</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-npm">NPM Integration</a></li>
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-npm" data-i18n="settings.tabNpm">NPM Integration</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-images">Docker Images</a></li>
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-images" data-i18n="settings.tabImages">Docker Images</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security">Security</a></li>
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-branding" data-i18n="settings.tabBranding">Branding</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-users" onclick="loadUsers()" data-i18n="settings.tabUsers">Users</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-azure" data-i18n="settings.tabAzure">Azure AD</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security" data-i18n="settings.tabSecurity">Security</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -260,30 +285,35 @@
|
|||||||
<form id="settings-system-form">
|
<form id="settings-system-form">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Base Domain</label>
|
<label class="form-label" data-i18n="settings.baseDomain">Base Domain</label>
|
||||||
<input type="text" class="form-control" id="cfg-base-domain" placeholder="yourdomain.com">
|
<input type="text" class="form-control" id="cfg-base-domain" data-i18n-placeholder="settings.baseDomainPlaceholder" placeholder="yourdomain.com">
|
||||||
<div class="form-text">Customers get subdomains: kunde.yourdomain.com</div>
|
<div class="form-text" data-i18n="settings.baseDomainHint">Customers get subdomains: customer.yourdomain.com</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Admin Email</label>
|
<label class="form-label" data-i18n="settings.adminEmail">Admin Email</label>
|
||||||
<input type="email" class="form-control" id="cfg-admin-email" placeholder="admin@yourdomain.com">
|
<input type="email" class="form-control" id="cfg-admin-email" data-i18n-placeholder="settings.adminEmailPlaceholder" placeholder="admin@yourdomain.com">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Data Directory</label>
|
<label class="form-label" data-i18n="settings.dataDir">Data Directory</label>
|
||||||
<input type="text" class="form-control" id="cfg-data-dir" placeholder="/opt/netbird-instances">
|
<input type="text" class="form-control" id="cfg-data-dir" data-i18n-placeholder="settings.dataDirPlaceholder" placeholder="/opt/netbird-instances">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Docker Network</label>
|
<label class="form-label" data-i18n="settings.dockerNetwork">Docker Network</label>
|
||||||
<input type="text" class="form-control" id="cfg-docker-network" placeholder="npm-network">
|
<input type="text" class="form-control" id="cfg-docker-network" data-i18n-placeholder="settings.dockerNetworkPlaceholder" placeholder="npm-network">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Relay Base Port</label>
|
<label class="form-label" data-i18n="settings.relayBasePort">Relay Base Port</label>
|
||||||
<input type="number" class="form-control" id="cfg-relay-base-port" min="1024" max="65535">
|
<input type="number" class="form-control" id="cfg-relay-base-port" min="1024" max="65535">
|
||||||
<div class="form-text">First UDP port for relay. Range: base to base+99</div>
|
<div class="form-text" data-i18n="settings.relayBasePortHint">First UDP port for relay. Range: base to base+99</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.dashboardBasePort">Dashboard Base Port</label>
|
||||||
|
<input type="number" class="form-control" id="cfg-dashboard-base-port" min="1024" max="65535">
|
||||||
|
<div class="form-text" data-i18n="settings.dashboardBasePortHint">Base port for customer dashboards. Customer N gets base+N</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save System Settings</button>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveSystemSettings">Save System Settings</span></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,31 +325,31 @@
|
|||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="settings-npm-form">
|
<form id="settings-npm-form">
|
||||||
<p class="text-muted mb-3">NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.</p>
|
<p class="text-muted mb-3" data-i18n="settings.npmDescription">NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.</p>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<label class="form-label">NPM API URL</label>
|
<label class="form-label" data-i18n="settings.npmApiUrl">NPM API URL</label>
|
||||||
<input type="text" class="form-control" id="cfg-npm-api-url" placeholder="http://nginx-proxy-manager:81/api">
|
<input type="text" class="form-control" id="cfg-npm-api-url" data-i18n-placeholder="settings.npmApiUrlPlaceholder" placeholder="http://nginx-proxy-manager:81/api">
|
||||||
<div class="form-text">http:// or https:// - must include /api at the end</div>
|
<div class="form-text" data-i18n="settings.npmApiUrlHint">http:// or https:// - must include /api at the end</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<label class="form-label">NPM Login Email</label>
|
<label class="form-label" data-i18n="settings.npmLoginEmail">NPM Login Email</label>
|
||||||
<input type="text" class="form-control" id="cfg-npm-api-email" placeholder="Leave empty to keep current">
|
<input type="text" class="form-control" id="cfg-npm-api-email" data-i18n-placeholder="settings.npmLoginEmailPlaceholder" placeholder="Leave empty to keep current">
|
||||||
<div class="form-text" id="npm-credentials-status"></div>
|
<div class="form-text" id="npm-credentials-status"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<label class="form-label">NPM Login Password</label>
|
<label class="form-label" data-i18n="settings.npmLoginPassword">NPM Login Password</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control" id="cfg-npm-api-password" placeholder="Leave empty to keep current">
|
<input type="password" class="form-control" id="cfg-npm-api-password" data-i18n-placeholder="settings.npmLoginPasswordPlaceholder" placeholder="Leave empty to keep current">
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-npm-api-password')"><i class="bi bi-eye"></i></button>
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-npm-api-password')"><i class="bi bi-eye"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i>Save NPM Settings</button>
|
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveNpmSettings">Save NPM Settings</span></button>
|
||||||
<button type="button" class="btn btn-outline-info" id="test-npm-btn" onclick="testNpmConnection()">
|
<button type="button" class="btn btn-outline-info" id="test-npm-btn" onclick="testNpmConnection()">
|
||||||
<span class="spinner-border spinner-border-sm d-none me-1" id="npm-test-spinner"></span>
|
<span class="spinner-border spinner-border-sm d-none me-1" id="npm-test-spinner"></span>
|
||||||
<i class="bi bi-plug me-1"></i>Test Connection
|
<i class="bi bi-plug me-1"></i><span data-i18n="settings.testConnection">Test Connection</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -335,24 +365,142 @@
|
|||||||
<form id="settings-images-form">
|
<form id="settings-images-form">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Management Image</label>
|
<label class="form-label" data-i18n="settings.managementImage">Management Image</label>
|
||||||
<input type="text" class="form-control" id="cfg-mgmt-image" placeholder="netbirdio/management:latest">
|
<input type="text" class="form-control" id="cfg-mgmt-image" data-i18n-placeholder="settings.managementImagePlaceholder" placeholder="netbirdio/management:latest">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Signal Image</label>
|
<label class="form-label" data-i18n="settings.signalImage">Signal Image</label>
|
||||||
<input type="text" class="form-control" id="cfg-signal-image" placeholder="netbirdio/signal:latest">
|
<input type="text" class="form-control" id="cfg-signal-image" data-i18n-placeholder="settings.signalImagePlaceholder" placeholder="netbirdio/signal:latest">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Relay Image</label>
|
<label class="form-label" data-i18n="settings.relayImage">Relay Image</label>
|
||||||
<input type="text" class="form-control" id="cfg-relay-image" placeholder="netbirdio/relay:latest">
|
<input type="text" class="form-control" id="cfg-relay-image" data-i18n-placeholder="settings.relayImagePlaceholder" placeholder="netbirdio/relay:latest">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Dashboard Image</label>
|
<label class="form-label" data-i18n="settings.dashboardImage">Dashboard Image</label>
|
||||||
<input type="text" class="form-control" id="cfg-dashboard-image" placeholder="netbirdio/dashboard:latest">
|
<input type="text" class="form-control" id="cfg-dashboard-image" data-i18n-placeholder="settings.dashboardImagePlaceholder" placeholder="netbirdio/dashboard:latest">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save Image Settings</button>
|
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveImageSettings">Save Image Settings</span></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Branding -->
|
||||||
|
<div class="tab-pane fade" id="settings-branding">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-3" data-i18n="settings.brandingTitle">Branding Settings</h5>
|
||||||
|
<form id="settings-branding-form">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.companyName">Company / Application Name</label>
|
||||||
|
<input type="text" class="form-control" id="cfg-branding-name" data-i18n-placeholder="settings.companyNamePlaceholder" placeholder="NetBird MSP Appliance" maxlength="255">
|
||||||
|
<div class="form-text" data-i18n="settings.companyNameHint">Displayed on login page and navbar</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.brandingSubtitle">Subtitle</label>
|
||||||
|
<input type="text" class="form-control" id="cfg-branding-subtitle" data-i18n-placeholder="settings.brandingSubtitlePlaceholder" placeholder="Multi-Tenant Management Platform" maxlength="255">
|
||||||
|
<div class="form-text" data-i18n="settings.brandingSubtitleHint">Shown below the title on the login page</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.defaultLanguage">Default Language</label>
|
||||||
|
<select class="form-select" id="cfg-default-language">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text" data-i18n="settings.defaultLanguageHint">Default language for users without a preference</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.logoPreview">Logo Preview</label>
|
||||||
|
<div class="border rounded p-3 text-center" id="branding-logo-preview" style="min-height:80px;">
|
||||||
|
<i class="bi bi-hdd-network fs-1 text-primary"></i>
|
||||||
|
<div class="text-muted small mt-1" data-i18n="settings.defaultIcon">Default icon (no logo uploaded)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.uploadLogo">Upload Logo (PNG, JPG, SVG, max 500KB)</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="file" class="form-control" id="branding-logo-file" accept=".png,.jpg,.jpeg,.svg">
|
||||||
|
<button type="button" class="btn btn-outline-primary" onclick="uploadLogo()"><i class="bi bi-upload me-1"></i><span data-i18n="settings.uploadBtn">Upload</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-end">
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="deleteLogo()"><i class="bi bi-trash me-1"></i><span data-i18n="settings.removeLogo">Remove Logo</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveBranding">Save Branding</span></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users -->
|
||||||
|
<div class="tab-pane fade" id="settings-users">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span data-i18n="settings.userManagement">User Management</span>
|
||||||
|
<button class="btn btn-sm btn-success" onclick="showNewUserModal()"><i class="bi bi-plus-lg me-1"></i><span data-i18n="settings.newUser">New User</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th data-i18n="settings.thId">ID</th>
|
||||||
|
<th data-i18n="settings.thUsername">Username</th>
|
||||||
|
<th data-i18n="settings.thEmail">Email</th>
|
||||||
|
<th data-i18n="settings.thRole">Role</th>
|
||||||
|
<th data-i18n="settings.thAuth">Auth</th>
|
||||||
|
<th data-i18n="settings.thLanguage">Language</th>
|
||||||
|
<th data-i18n="settings.thStatus">Status</th>
|
||||||
|
<th data-i18n="settings.thActions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="users-table-body">
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Azure AD -->
|
||||||
|
<div class="tab-pane fade" id="settings-azure">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-3" data-i18n="settings.azureTitle">Azure AD / Entra ID Integration</h5>
|
||||||
|
<form id="settings-azure-form">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="cfg-azure-enabled">
|
||||||
|
<label class="form-check-label" for="cfg-azure-enabled" data-i18n="settings.enableAzureSso">Enable Azure AD SSO</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.tenantId">Tenant ID</label>
|
||||||
|
<input type="text" class="form-control" id="cfg-azure-tenant" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.clientId">Client ID (Application ID)</label>
|
||||||
|
<input type="text" class="form-control" id="cfg-azure-client-id" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" data-i18n="settings.clientSecret">Client Secret</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="cfg-azure-client-secret" data-i18n-placeholder="settings.clientSecretPlaceholder" placeholder="Leave empty to keep current">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" data-toggle-pw onclick="togglePasswordVisibility('cfg-azure-client-secret')"><i class="bi bi-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text" id="azure-secret-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveAzureSettings">Save Azure AD Settings</span></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,25 +511,25 @@
|
|||||||
<div class="tab-pane fade" id="settings-security">
|
<div class="tab-pane fade" id="settings-security">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="mb-3">Change Admin Password</h5>
|
<h5 class="mb-3" data-i18n="settings.securityTitle">Change Admin Password</h5>
|
||||||
<form id="change-password-form">
|
<form id="change-password-form">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Current Password</label>
|
<label class="form-label" data-i18n="settings.currentPassword">Current Password</label>
|
||||||
<input type="password" class="form-control" id="pw-current" required>
|
<input type="password" class="form-control" id="pw-current" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6"></div>
|
<div class="col-md-6"></div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">New Password (min 12 chars)</label>
|
<label class="form-label" data-i18n="settings.newPassword">New Password (min 12 chars)</label>
|
||||||
<input type="password" class="form-control" id="pw-new" required minlength="12">
|
<input type="password" class="form-control" id="pw-new" required minlength="12">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Confirm New Password</label>
|
<label class="form-label" data-i18n="settings.confirmPassword">Confirm New Password</label>
|
||||||
<input type="password" class="form-control" id="pw-confirm" required minlength="12">
|
<input type="password" class="form-control" id="pw-confirm" required minlength="12">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button type="submit" class="btn btn-warning"><i class="bi bi-shield-lock me-1"></i>Change Password</button>
|
<button type="submit" class="btn btn-warning"><i class="bi bi-shield-lock me-1"></i><span data-i18n="settings.changePassword">Change Password</span></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div id="password-result" class="mt-3 d-none"></div>
|
<div id="password-result" class="mt-3 d-none"></div>
|
||||||
@@ -396,34 +544,38 @@
|
|||||||
<div id="page-monitoring" class="page-content d-none">
|
<div id="page-monitoring" class="page-content d-none">
|
||||||
<div class="container-fluid p-4">
|
<div class="container-fluid p-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h4 class="mb-0"><i class="bi bi-activity me-2"></i>System Monitoring</h4>
|
<h4 class="mb-0">
|
||||||
<button class="btn btn-outline-secondary btn-sm" onclick="loadMonitoring()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
|
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
|
||||||
|
<i class="bi bi-activity me-2"></i><span data-i18n="monitoring.title">System Monitoring</span>
|
||||||
|
</h4>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="loadMonitoring()"><i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="monitoring.refresh">Refresh</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Host Resources -->
|
<!-- Host Resources -->
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-header">Host Resources</div>
|
<div class="card-header" data-i18n="monitoring.hostResources">Host Resources</div>
|
||||||
<div class="card-body" id="monitoring-resources">Loading...</div>
|
<div class="card-body" id="monitoring-resources" data-i18n="common.loading">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Customer Statuses -->
|
<!-- Customer Statuses -->
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-header">All Customer Deployments</div>
|
<div class="card-header" data-i18n="monitoring.allCustomerDeployments">All Customer Deployments</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th data-i18n="monitoring.thId">ID</th>
|
||||||
<th>Name</th>
|
<th data-i18n="monitoring.thName">Name</th>
|
||||||
<th>Subdomain</th>
|
<th data-i18n="monitoring.thSubdomain">Subdomain</th>
|
||||||
<th>Status</th>
|
<th data-i18n="monitoring.thStatus">Status</th>
|
||||||
<th>Deployment</th>
|
<th data-i18n="monitoring.thDeployment">Deployment</th>
|
||||||
<th>Relay Port</th>
|
<th data-i18n="monitoring.thDashboard">Dashboard</th>
|
||||||
<th>Containers</th>
|
<th data-i18n="monitoring.thRelayPort">Relay Port</th>
|
||||||
|
<th data-i18n="monitoring.thContainers">Containers</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="monitoring-customers-body">
|
<tbody id="monitoring-customers-body">
|
||||||
<tr><td colspan="7" class="text-center text-muted py-4">Loading...</td></tr>
|
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -437,7 +589,7 @@
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="customer-modal-title">New Customer</h5>
|
<h5 class="modal-title" id="customer-modal-title" data-i18n="customerModal.newCustomer">New Customer</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -445,40 +597,40 @@
|
|||||||
<form id="customer-form">
|
<form id="customer-form">
|
||||||
<input type="hidden" id="customer-edit-id">
|
<input type="hidden" id="customer-edit-id">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Name *</label>
|
<label class="form-label" data-i18n="customerModal.nameLabel">Name *</label>
|
||||||
<input type="text" class="form-control" id="cust-name" required>
|
<input type="text" class="form-control" id="cust-name" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Company</label>
|
<label class="form-label" data-i18n="customerModal.companyLabel">Company</label>
|
||||||
<input type="text" class="form-control" id="cust-company">
|
<input type="text" class="form-control" id="cust-company">
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Subdomain *</label>
|
<label class="form-label" data-i18n="customerModal.subdomainLabel">Subdomain *</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" class="form-control" id="cust-subdomain" required pattern="[a-z0-9][-a-z0-9]*[a-z0-9]">
|
<input type="text" class="form-control" id="cust-subdomain" required pattern="[a-z0-9][-a-z0-9]*[a-z0-9]">
|
||||||
<span class="input-group-text" id="cust-subdomain-suffix">.domain.com</span>
|
<span class="input-group-text" id="cust-subdomain-suffix">.domain.com</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Lowercase, alphanumeric + hyphens</div>
|
<div class="form-text" data-i18n="customerModal.subdomainHint">Lowercase, alphanumeric + hyphens</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Email *</label>
|
<label class="form-label" data-i18n="customerModal.emailLabel">Email *</label>
|
||||||
<input type="email" class="form-control" id="cust-email" required>
|
<input type="email" class="form-control" id="cust-email" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Max Devices</label>
|
<label class="form-label" data-i18n="customerModal.maxDevicesLabel">Max Devices</label>
|
||||||
<input type="number" class="form-control" id="cust-max-devices" value="20" min="1">
|
<input type="number" class="form-control" id="cust-max-devices" value="20" min="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Notes</label>
|
<label class="form-label" data-i18n="customerModal.notesLabel">Notes</label>
|
||||||
<textarea class="form-control" id="cust-notes" rows="2"></textarea>
|
<textarea class="form-control" id="cust-notes" rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" id="customer-save-btn" onclick="saveCustomer()">
|
<button type="button" class="btn btn-primary" id="customer-save-btn" onclick="saveCustomer()">
|
||||||
<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span>
|
<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span>
|
||||||
Save & Deploy
|
<span data-i18n="customerModal.saveAndDeploy">Save & Deploy</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,26 +642,68 @@
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header bg-danger text-white">
|
<div class="modal-header bg-danger text-white">
|
||||||
<h5 class="modal-title">Confirm Deletion</h5>
|
<h5 class="modal-title" data-i18n="deleteModal.title">Confirm Deletion</h5>
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to delete customer <strong id="delete-customer-name"></strong>?</p>
|
<p><span data-i18n="deleteModal.confirmText">Are you sure you want to delete customer</span> <strong id="delete-customer-name"></strong>?</p>
|
||||||
<p class="text-danger">This will remove all containers, NPM entries, and data. This action cannot be undone.</p>
|
<p class="text-danger" data-i18n="deleteModal.warning">This will remove all containers, NPM entries, and data. This action cannot be undone.</p>
|
||||||
<input type="hidden" id="delete-customer-id">
|
<input type="hidden" id="delete-customer-id">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
|
||||||
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">
|
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">
|
||||||
<span class="spinner-border spinner-border-sm d-none me-1" id="delete-spinner"></span>
|
<span class="spinner-border spinner-border-sm d-none me-1" id="delete-spinner"></span>
|
||||||
Delete
|
<span data-i18n="common.delete">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: New User -->
|
||||||
|
<div class="modal fade" id="user-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" data-i18n="userModal.title">New User</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="user-modal-error" class="alert alert-danger d-none"></div>
|
||||||
|
<form id="user-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" data-i18n="userModal.usernameLabel">Username *</label>
|
||||||
|
<input type="text" class="form-control" id="new-user-username" required minlength="3">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" data-i18n="userModal.passwordLabel">Password * (min 8 chars)</label>
|
||||||
|
<input type="password" class="form-control" id="new-user-password" required minlength="8">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" data-i18n="userModal.emailLabel">Email</label>
|
||||||
|
<input type="email" class="form-control" id="new-user-email">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" data-i18n="userModal.languageLabel">Default Language</label>
|
||||||
|
<select class="form-select" id="new-user-language">
|
||||||
|
<option value="" data-i18n="settings.systemDefault">System Default</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveNewUser()" data-i18n="userModal.createUser">Create User</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/static/js/i18n.js"></script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
586
static/js/app.js
586
static/js/app.js
@@ -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 brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null };
|
||||||
|
let azureConfig = { azure_enabled: false };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// API helper
|
// API helper
|
||||||
@@ -32,21 +34,32 @@ async function api(method, path, body = null) {
|
|||||||
resp = await fetch(`/api${path}`, opts);
|
resp = await fetch(`/api${path}`, opts);
|
||||||
} catch (networkErr) {
|
} catch (networkErr) {
|
||||||
console.error(`API network error: ${method} ${path}`, networkErr);
|
console.error(`API network error: ${method} ${path}`, networkErr);
|
||||||
throw new Error('Network error — server not reachable.');
|
throw new Error(t('errors.networkError'));
|
||||||
}
|
}
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
logout();
|
logout();
|
||||||
throw new Error('Session expired.');
|
throw new Error(t('errors.sessionExpired'));
|
||||||
}
|
}
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await resp.json();
|
data = await resp.json();
|
||||||
} catch (jsonErr) {
|
} catch (jsonErr) {
|
||||||
console.error(`API JSON parse error: ${method} ${path} (status ${resp.status})`, jsonErr);
|
console.error(`API JSON parse error: ${method} ${path} (status ${resp.status})`, jsonErr);
|
||||||
throw new Error(`Server error (HTTP ${resp.status}).`);
|
throw new Error(t('errors.serverError', { status: resp.status }));
|
||||||
}
|
}
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const msg = data.detail || data.message || 'Request failed.';
|
let msg = t('errors.requestFailed');
|
||||||
|
if (Array.isArray(data.detail)) {
|
||||||
|
msg = data.detail.map(e => {
|
||||||
|
const field = e.loc ? e.loc[e.loc.length - 1] : '';
|
||||||
|
const text = (e.msg || '').replace(/^Value error, ?/, '');
|
||||||
|
return field ? `${field}: ${text}` : text;
|
||||||
|
}).join('\n');
|
||||||
|
} else if (typeof data.detail === 'string') {
|
||||||
|
msg = data.detail;
|
||||||
|
} else if (data.message) {
|
||||||
|
msg = data.message;
|
||||||
|
}
|
||||||
console.error(`API error: ${method} ${path} (status ${resp.status})`, msg);
|
console.error(`API error: ${method} ${path} (status ${resp.status})`, msg);
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
@@ -56,20 +69,27 @@ async function api(method, path, body = null) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Auth
|
// Auth
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function initApp() {
|
async function initApp() {
|
||||||
|
await initI18n();
|
||||||
|
await loadBranding();
|
||||||
|
await loadAzureLoginConfig();
|
||||||
|
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
api('GET', '/auth/me')
|
try {
|
||||||
.then(user => {
|
const user = await api('GET', '/auth/me');
|
||||||
currentUser = user;
|
currentUser = user;
|
||||||
document.getElementById('nav-username').textContent = user.username;
|
document.getElementById('nav-username').textContent = user.username;
|
||||||
|
// Apply user's language preference if set
|
||||||
|
if (user.default_language && !localStorage.getItem('language')) {
|
||||||
|
await setLanguage(user.default_language);
|
||||||
|
}
|
||||||
showAppPage();
|
showAppPage();
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
})
|
} catch {
|
||||||
.catch(() => {
|
|
||||||
authToken = null;
|
authToken = null;
|
||||||
localStorage.removeItem('authToken');
|
localStorage.removeItem('authToken');
|
||||||
showLoginPage();
|
showLoginPage();
|
||||||
});
|
}
|
||||||
} else {
|
} else {
|
||||||
showLoginPage();
|
showLoginPage();
|
||||||
}
|
}
|
||||||
@@ -85,6 +105,112 @@ function showAppPage() {
|
|||||||
document.getElementById('app-page').classList.remove('d-none');
|
document.getElementById('app-page').classList.remove('d-none');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBranding() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/settings/branding');
|
||||||
|
if (resp.ok) {
|
||||||
|
brandingData = await resp.json();
|
||||||
|
// Set system default language from server config
|
||||||
|
if (brandingData.default_language) {
|
||||||
|
setSystemDefault(brandingData.default_language);
|
||||||
|
}
|
||||||
|
applyBranding();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBranding() {
|
||||||
|
const name = brandingData.branding_name || 'NetBird MSP Appliance';
|
||||||
|
const subtitle = brandingData.branding_subtitle || t('login.subtitle');
|
||||||
|
const logoPath = brandingData.branding_logo_path;
|
||||||
|
|
||||||
|
// Login page
|
||||||
|
document.getElementById('login-title').textContent = name;
|
||||||
|
const subtitleEl = document.getElementById('login-subtitle');
|
||||||
|
if (subtitleEl) subtitleEl.textContent = subtitle;
|
||||||
|
document.title = name;
|
||||||
|
if (logoPath) {
|
||||||
|
document.getElementById('login-logo').innerHTML = `<img src="${logoPath}" alt="Logo" style="max-height:64px;max-width:200px;" class="mb-1">`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('login-logo').innerHTML = '<i class="bi bi-hdd-network fs-1 text-primary"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navbar — use short form for the nav bar
|
||||||
|
const shortName = name.length > 30 ? name.substring(0, 30) + '\u2026' : name;
|
||||||
|
document.getElementById('nav-brand-name').textContent = shortName;
|
||||||
|
if (logoPath) {
|
||||||
|
document.getElementById('nav-logo').innerHTML = `<img src="${logoPath}" alt="Logo" style="height:28px;max-width:120px;" class="me-2">`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('nav-logo').innerHTML = '<i class="bi bi-hdd-network me-2"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAzureLoginConfig() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/azure/config');
|
||||||
|
if (resp.ok) {
|
||||||
|
azureConfig = await resp.json();
|
||||||
|
if (azureConfig.azure_enabled) {
|
||||||
|
document.getElementById('azure-login-divider').classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
document.getElementById('azure-login-divider').classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Azure not configured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginWithAzure() {
|
||||||
|
if (!azureConfig.azure_enabled || !azureConfig.azure_tenant_id || !azureConfig.azure_client_id) {
|
||||||
|
alert(t('errors.azureNotConfigured'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const redirectUri = window.location.origin + '/';
|
||||||
|
const authUrl = `https://login.microsoftonline.com/${azureConfig.azure_tenant_id}/oauth2/v2.0/authorize`
|
||||||
|
+ `?client_id=${azureConfig.azure_client_id}`
|
||||||
|
+ `&response_type=code`
|
||||||
|
+ `&redirect_uri=${encodeURIComponent(redirectUri)}`
|
||||||
|
+ `&scope=${encodeURIComponent('openid profile email User.Read')}`
|
||||||
|
+ `&response_mode=query`;
|
||||||
|
window.location.href = authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAzureCallback() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get('code');
|
||||||
|
if (!code) return false;
|
||||||
|
|
||||||
|
// Clear URL params
|
||||||
|
window.history.replaceState({}, document.title, '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api('POST', '/auth/azure/callback', {
|
||||||
|
code: code,
|
||||||
|
redirect_uri: window.location.origin + '/',
|
||||||
|
});
|
||||||
|
authToken = data.access_token;
|
||||||
|
localStorage.setItem('authToken', authToken);
|
||||||
|
currentUser = data.user;
|
||||||
|
document.getElementById('nav-username').textContent = currentUser.username;
|
||||||
|
// Apply user's language preference
|
||||||
|
if (currentUser.default_language) {
|
||||||
|
await setLanguage(currentUser.default_language);
|
||||||
|
}
|
||||||
|
showAppPage();
|
||||||
|
loadDashboard();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('login-error');
|
||||||
|
errorEl.textContent = t('errors.azureLoginFailed', { error: err.message });
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
showLoginPage();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const errorEl = document.getElementById('login-error');
|
const errorEl = document.getElementById('login-error');
|
||||||
@@ -101,6 +227,10 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|||||||
localStorage.setItem('authToken', authToken);
|
localStorage.setItem('authToken', authToken);
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
document.getElementById('nav-username').textContent = currentUser.username;
|
document.getElementById('nav-username').textContent = currentUser.username;
|
||||||
|
// Apply user's language preference
|
||||||
|
if (currentUser.default_language) {
|
||||||
|
await setLanguage(currentUser.default_language);
|
||||||
|
}
|
||||||
showAppPage();
|
showAppPage();
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -128,6 +258,23 @@ function logout() {
|
|||||||
showLoginPage();
|
showLoginPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Language switching (saves preference to server for logged-in users)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function switchLanguage(lang) {
|
||||||
|
await setLanguage(lang);
|
||||||
|
applyBranding();
|
||||||
|
// Save preference to server if user is logged in
|
||||||
|
if (currentUser && currentUser.id) {
|
||||||
|
try {
|
||||||
|
await api('PUT', `/users/${currentUser.id}`, { default_language: lang });
|
||||||
|
currentUser.default_language = lang;
|
||||||
|
} catch {
|
||||||
|
// Silently fail — localStorage already saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Navigation
|
// Navigation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -178,39 +325,44 @@ async function loadCustomers() {
|
|||||||
function renderCustomersTable(data) {
|
function renderCustomersTable(data) {
|
||||||
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">No customers found. Click "New Customer" to create one.</td></tr>';
|
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`;
|
||||||
document.getElementById('pagination-info').textContent = 'Showing 0 of 0';
|
document.getElementById('pagination-info').textContent = t('dashboard.showingEmpty');
|
||||||
document.getElementById('pagination-controls').innerHTML = '';
|
document.getElementById('pagination-controls').innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = data.items.map(c => `
|
tbody.innerHTML = data.items.map(c => {
|
||||||
<tr>
|
const dPort = c.deployment && c.deployment.dashboard_port;
|
||||||
|
const dashUrl = c.deployment && c.deployment.setup_url;
|
||||||
|
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>`
|
||||||
|
: '-';
|
||||||
|
return `<tr>
|
||||||
<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>${esc(c.company || '-')}</td>
|
|
||||||
<td><code>${esc(c.subdomain)}</code></td>
|
<td><code>${esc(c.subdomain)}</code></td>
|
||||||
<td>${statusBadge(c.status)}</td>
|
<td>${statusBadge(c.status)}</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>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn btn-outline-primary" title="View" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button>
|
<button class="btn btn-outline-primary" title="${t('common.view')}" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button>
|
||||||
${c.deployment && c.deployment.deployment_status === 'running'
|
${c.deployment && c.deployment.deployment_status === 'running'
|
||||||
? `<button class="btn btn-outline-warning" title="Stop" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>`
|
? `<button class="btn btn-outline-warning" title="${t('common.stop')}" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>`
|
||||||
: `<button class="btn btn-outline-success" title="Start" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>`
|
: `<button class="btn btn-outline-success" title="${t('common.start')}" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>`
|
||||||
}
|
}
|
||||||
<button class="btn btn-outline-info" title="Restart" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button>
|
<button class="btn btn-outline-info" title="${t('common.restart')}" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button>
|
||||||
<button class="btn btn-outline-danger" title="Delete" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const start = (data.page - 1) * data.per_page + 1;
|
const start = (data.page - 1) * data.per_page + 1;
|
||||||
const end = Math.min(data.page * data.per_page, data.total);
|
const end = Math.min(data.page * data.per_page, data.total);
|
||||||
document.getElementById('pagination-info').textContent = `Showing ${start}-${end} of ${data.total}`;
|
document.getElementById('pagination-info').textContent = t('dashboard.showing', { start, end, total: data.total });
|
||||||
|
|
||||||
let paginationHtml = '';
|
let paginationHtml = '';
|
||||||
for (let i = 1; i <= data.pages; i++) {
|
for (let i = 1; i <= data.pages; i++) {
|
||||||
@@ -232,12 +384,13 @@ document.getElementById('status-filter').addEventListener('change', () => { cust
|
|||||||
// Customer CRUD
|
// Customer CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function showNewCustomerModal() {
|
function showNewCustomerModal() {
|
||||||
document.getElementById('customer-modal-title').textContent = 'New Customer';
|
document.getElementById('customer-modal-title').textContent = t('customerModal.newCustomer');
|
||||||
document.getElementById('customer-edit-id').value = '';
|
document.getElementById('customer-edit-id').value = '';
|
||||||
document.getElementById('customer-form').reset();
|
document.getElementById('customer-form').reset();
|
||||||
document.getElementById('cust-max-devices').value = '20';
|
document.getElementById('cust-max-devices').value = '20';
|
||||||
document.getElementById('customer-modal-error').classList.add('d-none');
|
document.getElementById('customer-modal-error').classList.add('d-none');
|
||||||
document.getElementById('customer-save-btn').innerHTML = '<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span> Save & Deploy';
|
const saveBtnSpan = document.getElementById('customer-save-btn').querySelector('span[data-i18n]');
|
||||||
|
if (saveBtnSpan) saveBtnSpan.textContent = t('customerModal.saveAndDeploy');
|
||||||
|
|
||||||
// Update subdomain suffix
|
// Update subdomain suffix
|
||||||
api('GET', '/settings/system').then(cfg => {
|
api('GET', '/settings/system').then(cfg => {
|
||||||
@@ -254,7 +407,7 @@ function showNewCustomerModal() {
|
|||||||
function editCurrentCustomer() {
|
function editCurrentCustomer() {
|
||||||
if (!currentCustomerData) return;
|
if (!currentCustomerData) return;
|
||||||
const c = currentCustomerData;
|
const c = currentCustomerData;
|
||||||
document.getElementById('customer-modal-title').textContent = 'Edit Customer';
|
document.getElementById('customer-modal-title').textContent = t('customerModal.editCustomer');
|
||||||
document.getElementById('customer-edit-id').value = c.id;
|
document.getElementById('customer-edit-id').value = c.id;
|
||||||
document.getElementById('cust-name').value = c.name;
|
document.getElementById('cust-name').value = c.name;
|
||||||
document.getElementById('cust-company').value = c.company || '';
|
document.getElementById('cust-company').value = c.company || '';
|
||||||
@@ -264,7 +417,8 @@ function editCurrentCustomer() {
|
|||||||
document.getElementById('cust-max-devices').value = c.max_devices;
|
document.getElementById('cust-max-devices').value = c.max_devices;
|
||||||
document.getElementById('cust-notes').value = c.notes || '';
|
document.getElementById('cust-notes').value = c.notes || '';
|
||||||
document.getElementById('customer-modal-error').classList.add('d-none');
|
document.getElementById('customer-modal-error').classList.add('d-none');
|
||||||
document.getElementById('customer-save-btn').innerHTML = '<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span> Save Changes';
|
const saveBtnSpan = document.getElementById('customer-save-btn').querySelector('span[data-i18n]');
|
||||||
|
if (saveBtnSpan) saveBtnSpan.textContent = t('customerModal.saveChanges');
|
||||||
|
|
||||||
const modalEl = document.getElementById('customer-modal');
|
const modalEl = document.getElementById('customer-modal');
|
||||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||||
@@ -309,7 +463,7 @@ async function saveCustomer() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('saveCustomer error:', err);
|
console.error('saveCustomer error:', err);
|
||||||
errorEl.textContent = err.message || 'An unknown error occurred.';
|
errorEl.textContent = err.message || t('errors.unknownError');
|
||||||
errorEl.classList.remove('d-none');
|
errorEl.classList.remove('d-none');
|
||||||
errorEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
errorEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -338,7 +492,7 @@ async function confirmDeleteCustomer() {
|
|||||||
bootstrap.Modal.getInstance(document.getElementById('delete-modal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('delete-modal')).hide();
|
||||||
showPage('dashboard');
|
showPage('dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Delete failed: ' + err.message);
|
alert(t('errors.deleteFailed', { error: err.message }));
|
||||||
} finally {
|
} finally {
|
||||||
spinner.classList.add('d-none');
|
spinner.classList.add('d-none');
|
||||||
}
|
}
|
||||||
@@ -353,7 +507,7 @@ async function customerAction(id, action) {
|
|||||||
if (currentPage === 'dashboard') loadCustomers();
|
if (currentPage === 'dashboard') loadCustomers();
|
||||||
if (currentCustomerId == id) viewCustomer(id);
|
if (currentCustomerId == id) viewCustomer(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`${action} failed: ${err.message}`);
|
alert(t('errors.actionFailed', { action, error: err.message }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,15 +528,15 @@ async function viewCustomer(id) {
|
|||||||
// Info tab
|
// Info tab
|
||||||
document.getElementById('detail-info-content').innerHTML = `
|
document.getElementById('detail-info-content').innerHTML = `
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6"><strong>Name:</strong> ${esc(data.name)}</div>
|
<div class="col-md-6"><strong>${t('customer.name')}</strong> ${esc(data.name)}</div>
|
||||||
<div class="col-md-6"><strong>Company:</strong> ${esc(data.company || '-')}</div>
|
<div class="col-md-6"><strong>${t('customer.company')}</strong> ${esc(data.company || '-')}</div>
|
||||||
<div class="col-md-6"><strong>Subdomain:</strong> <code>${esc(data.subdomain)}</code></div>
|
<div class="col-md-6"><strong>${t('customer.subdomain')}</strong> <code>${esc(data.subdomain)}</code></div>
|
||||||
<div class="col-md-6"><strong>Email:</strong> ${esc(data.email)}</div>
|
<div class="col-md-6"><strong>${t('customer.email')}</strong> ${esc(data.email)}</div>
|
||||||
<div class="col-md-6"><strong>Max Devices:</strong> ${data.max_devices}</div>
|
<div class="col-md-6"><strong>${t('customer.maxDevices')}</strong> ${data.max_devices}</div>
|
||||||
<div class="col-md-6"><strong>Status:</strong> ${statusBadge(data.status)}</div>
|
<div class="col-md-6"><strong>${t('customer.status')}</strong> ${statusBadge(data.status)}</div>
|
||||||
<div class="col-md-6"><strong>Created:</strong> ${formatDate(data.created_at)}</div>
|
<div class="col-md-6"><strong>${t('customer.created')}</strong> ${formatDate(data.created_at)}</div>
|
||||||
<div class="col-md-6"><strong>Updated:</strong> ${formatDate(data.updated_at)}</div>
|
<div class="col-md-6"><strong>${t('customer.updated')}</strong> ${formatDate(data.updated_at)}</div>
|
||||||
${data.notes ? `<div class="col-12"><strong>Notes:</strong> ${esc(data.notes)}</div>` : ''}
|
${data.notes ? `<div class="col-12"><strong>${t('customer.notes')}</strong> ${esc(data.notes)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -391,29 +545,62 @@ async function viewCustomer(id) {
|
|||||||
const d = data.deployment;
|
const d = data.deployment;
|
||||||
document.getElementById('detail-deployment-content').innerHTML = `
|
document.getElementById('detail-deployment-content').innerHTML = `
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6"><strong>Status:</strong> ${statusBadge(d.deployment_status)}</div>
|
<div class="col-md-6"><strong>${t('customer.deploymentStatus')}</strong> ${statusBadge(d.deployment_status)}</div>
|
||||||
<div class="col-md-6"><strong>Relay UDP Port:</strong> ${d.relay_udp_port}</div>
|
<div class="col-md-6"><strong>${t('customer.relayUdpPort')}</strong> ${d.relay_udp_port}</div>
|
||||||
<div class="col-md-6"><strong>Container Prefix:</strong> <code>${esc(d.container_prefix)}</code></div>
|
<div class="col-md-6"><strong>${t('customer.dashboardPort')}</strong> ${d.dashboard_port || '-'}${d.dashboard_port ? ` <a href="${esc(d.setup_url || 'http://localhost:' + d.dashboard_port)}" target="_blank" class="ms-2"><i class="bi bi-box-arrow-up-right"></i> ${t('customer.open')}</a>` : ''}</div>
|
||||||
<div class="col-md-6"><strong>Deployed:</strong> ${formatDate(d.deployed_at)}</div>
|
<div class="col-md-6"><strong>${t('customer.containerPrefix')}</strong> <code>${esc(d.container_prefix)}</code></div>
|
||||||
|
<div class="col-md-6"><strong>${t('customer.deployed')}</strong> ${formatDate(d.deployed_at)}</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<strong>Setup URL:</strong>
|
<strong>${t('customer.setupUrl')}</strong>
|
||||||
<div class="input-group mt-1">
|
<div class="input-group mt-1">
|
||||||
<input type="text" class="form-control" value="${esc(d.setup_url || '')}" readonly id="setup-url-input">
|
<input type="text" class="form-control" value="${esc(d.setup_url || '')}" readonly id="setup-url-input">
|
||||||
<button class="btn btn-outline-secondary" onclick="copySetupUrl()"><i class="bi bi-clipboard"></i> Copy</button>
|
<button class="btn btn-outline-secondary" onclick="copySetupUrl()"><i class="bi bi-clipboard"></i> ${t('customer.copy')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong><i class="bi bi-key me-1"></i>${t('customer.netbirdLogin')}</strong>
|
||||||
|
${d.has_credentials ? '' : `<span class="badge bg-secondary">${t('customer.notAvailable')}</span>`}
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="credentials-container">
|
||||||
|
${d.has_credentials ? `
|
||||||
|
<div id="credentials-placeholder">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" onclick="loadCredentials(${id})">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>${t('customer.showCredentials')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="credentials-content" style="display:none">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label mb-1"><small>${t('customer.credEmail')}</small></label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" class="form-control" id="cred-email" readonly>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="copyCredential('cred-email')" title="${t('customer.copy')}"><i class="bi bi-clipboard"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label mb-1"><small>${t('customer.credPassword')}</small></label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="password" class="form-control" id="cred-password" readonly>
|
||||||
|
<button class="btn btn-outline-secondary" data-toggle-pw onclick="togglePasswordVisibility('cred-password')" title="${t('customer.showHide')}"><i class="bi bi-eye"></i></button>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="copyCredential('cred-password')" title="${t('customer.copy')}"><i class="bi bi-clipboard"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `<p class="text-muted mb-0">${t('customer.credentialsNotAvailable')}</p>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>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>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>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>Re-Deploy</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('detail-deployment-content').innerHTML = `
|
document.getElementById('detail-deployment-content').innerHTML = `
|
||||||
<p class="text-muted">No deployment found.</p>
|
<p class="text-muted">${t('customer.noDeployment')}</p>
|
||||||
<button class="btn btn-primary" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Deploy Now</button>
|
<button class="btn btn-primary" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.deployNow')}</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,7 +621,7 @@ async function loadCustomerLogs() {
|
|||||||
const data = await api('GET', `/customers/${currentCustomerId}/logs`);
|
const data = await api('GET', `/customers/${currentCustomerId}/logs`);
|
||||||
const content = document.getElementById('detail-logs-content');
|
const content = document.getElementById('detail-logs-content');
|
||||||
if (!data.logs || Object.keys(data.logs).length === 0) {
|
if (!data.logs || Object.keys(data.logs).length === 0) {
|
||||||
content.innerHTML = '<p class="text-muted">No container logs available.</p>';
|
content.innerHTML = `<p class="text-muted">${t('customer.noContainerLogs')}</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '';
|
let html = '';
|
||||||
@@ -452,16 +639,18 @@ async function loadCustomerHealth() {
|
|||||||
try {
|
try {
|
||||||
const data = await api('GET', `/customers/${currentCustomerId}/health`);
|
const data = await api('GET', `/customers/${currentCustomerId}/health`);
|
||||||
const content = document.getElementById('detail-health-content');
|
const content = document.getElementById('detail-health-content');
|
||||||
let html = `<div class="mb-3"><strong>Overall:</strong> ${data.healthy ? '<span class="text-success">Healthy</span>' : '<span class="text-danger">Unhealthy</span>'}</div>`;
|
let html = `<div class="mb-3"><strong>${t('customer.overall')}</strong> ${data.healthy ? `<span class="text-success">${t('customer.healthy')}</span>` : `<span class="text-danger">${t('customer.unhealthy')}</span>`}</div>`;
|
||||||
if (data.containers && data.containers.length > 0) {
|
if (data.containers && data.containers.length > 0) {
|
||||||
html += '<table class="table table-sm"><thead><tr><th>Container</th><th>Status</th><th>Health</th><th>Image</th></tr></thead><tbody>';
|
html += `<table class="table table-sm"><thead><tr><th>${t('customer.thContainer')}</th><th>${t('customer.thContainerStatus')}</th><th>${t('customer.thHealth')}</th><th>${t('customer.thImage')}</th></tr></thead><tbody>`;
|
||||||
data.containers.forEach(c => {
|
data.containers.forEach(c => {
|
||||||
const statusClass = c.status === 'running' ? 'text-success' : 'text-danger';
|
const statusClass = c.status === 'running' ? 'text-success' : 'text-danger';
|
||||||
html += `<tr><td>${esc(c.name)}</td><td class="${statusClass}">${c.status}</td><td>${c.health}</td><td><code>${esc(c.image)}</code></td></tr>`;
|
const healthClass = c.health === 'healthy' ? 'text-success' : 'text-danger';
|
||||||
|
const healthLabel = c.health === 'healthy' ? t('customer.healthy') : t('customer.unhealthy');
|
||||||
|
html += `<tr><td>${esc(c.name)}</td><td class="${statusClass}">${c.status}</td><td class="${healthClass}">${healthLabel}</td><td><code>${esc(c.image)}</code></td></tr>`;
|
||||||
});
|
});
|
||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
}
|
}
|
||||||
html += `<div class="text-muted small">Last check: ${formatDate(data.last_check)}</div>`;
|
html += `<div class="text-muted small">${t('customer.lastCheck', { time: formatDate(data.last_check) })}</div>`;
|
||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('detail-health-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
document.getElementById('detail-health-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
||||||
@@ -471,7 +660,29 @@ async function loadCustomerHealth() {
|
|||||||
function copySetupUrl() {
|
function copySetupUrl() {
|
||||||
const input = document.getElementById('setup-url-input');
|
const input = document.getElementById('setup-url-input');
|
||||||
navigator.clipboard.writeText(input.value).then(() => {
|
navigator.clipboard.writeText(input.value).then(() => {
|
||||||
showToast('Setup URL copied to clipboard.');
|
showToast(t('messages.setupUrlCopied'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCredentials(customerId) {
|
||||||
|
try {
|
||||||
|
const data = await api('GET', `/customers/${customerId}/credentials`);
|
||||||
|
document.getElementById('cred-email').value = data.email;
|
||||||
|
document.getElementById('cred-password').value = data.password;
|
||||||
|
document.getElementById('credentials-placeholder').style.display = 'none';
|
||||||
|
document.getElementById('credentials-content').style.display = 'block';
|
||||||
|
} catch (err) {
|
||||||
|
showToast(t('errors.failedToLoadCredentials', { error: err.message }), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCredential(fieldId) {
|
||||||
|
const input = document.getElementById(fieldId);
|
||||||
|
const origType = input.type;
|
||||||
|
input.type = 'text';
|
||||||
|
navigator.clipboard.writeText(input.value).then(() => {
|
||||||
|
input.type = origType;
|
||||||
|
showToast(t('messages.copiedToClipboard'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,14 +697,36 @@ async function loadSettings() {
|
|||||||
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
|
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
|
||||||
document.getElementById('cfg-docker-network').value = cfg.docker_network || '';
|
document.getElementById('cfg-docker-network').value = cfg.docker_network || '';
|
||||||
document.getElementById('cfg-relay-base-port').value = cfg.relay_base_port || 3478;
|
document.getElementById('cfg-relay-base-port').value = cfg.relay_base_port || 3478;
|
||||||
|
document.getElementById('cfg-dashboard-base-port').value = cfg.dashboard_base_port || 9000;
|
||||||
document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || '';
|
document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || '';
|
||||||
document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? 'Credentials are set (leave empty to keep current)' : 'No NPM credentials configured';
|
document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? t('settings.credentialsSet') : t('settings.noCredentials');
|
||||||
document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || '';
|
document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || '';
|
||||||
document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || '';
|
document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || '';
|
||||||
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 || '';
|
||||||
|
|
||||||
|
// Branding tab
|
||||||
|
document.getElementById('cfg-branding-name').value = cfg.branding_name || '';
|
||||||
|
document.getElementById('cfg-branding-subtitle').value = cfg.branding_subtitle || '';
|
||||||
|
document.getElementById('cfg-default-language').value = cfg.default_language || 'en';
|
||||||
|
updateLogoPreview(cfg.branding_logo_path);
|
||||||
|
|
||||||
|
// Azure AD tab
|
||||||
|
document.getElementById('cfg-azure-enabled').checked = cfg.azure_enabled || false;
|
||||||
|
document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || '';
|
||||||
|
document.getElementById('cfg-azure-client-id').value = cfg.azure_client_id || '';
|
||||||
|
document.getElementById('azure-secret-status').textContent = cfg.azure_client_secret_set ? t('settings.secretSet') : t('settings.noSecret');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showSettingsAlert('danger', 'Failed to load settings: ' + err.message);
|
showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLogoPreview(logoPath) {
|
||||||
|
const preview = document.getElementById('branding-logo-preview');
|
||||||
|
if (logoPath) {
|
||||||
|
preview.innerHTML = `<img src="${logoPath}" alt="Logo" style="max-height:64px;max-width:200px;"><div class="text-muted small mt-1">${logoPath}</div>`;
|
||||||
|
} else {
|
||||||
|
preview.innerHTML = `<i class="bi bi-hdd-network fs-1 text-primary"></i><div class="text-muted small mt-1">${t('settings.defaultIcon')}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,10 +740,11 @@ document.getElementById('settings-system-form').addEventListener('submit', async
|
|||||||
data_dir: document.getElementById('cfg-data-dir').value,
|
data_dir: document.getElementById('cfg-data-dir').value,
|
||||||
docker_network: document.getElementById('cfg-docker-network').value,
|
docker_network: document.getElementById('cfg-docker-network').value,
|
||||||
relay_base_port: parseInt(document.getElementById('cfg-relay-base-port').value),
|
relay_base_port: parseInt(document.getElementById('cfg-relay-base-port').value),
|
||||||
|
dashboard_base_port: parseInt(document.getElementById('cfg-dashboard-base-port').value),
|
||||||
});
|
});
|
||||||
showSettingsAlert('success', 'System settings saved.');
|
showSettingsAlert('success', t('messages.systemSettingsSaved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showSettingsAlert('danger', 'Failed: ' + err.message);
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -524,12 +758,12 @@ document.getElementById('settings-npm-form').addEventListener('submit', async (e
|
|||||||
if (password) payload.npm_api_password = password;
|
if (password) payload.npm_api_password = password;
|
||||||
try {
|
try {
|
||||||
await api('PUT', '/settings/system', payload);
|
await api('PUT', '/settings/system', payload);
|
||||||
showSettingsAlert('success', 'NPM settings saved.');
|
showSettingsAlert('success', t('messages.npmSettingsSaved'));
|
||||||
document.getElementById('cfg-npm-api-email').value = '';
|
document.getElementById('cfg-npm-api-email').value = '';
|
||||||
document.getElementById('cfg-npm-api-password').value = '';
|
document.getElementById('cfg-npm-api-password').value = '';
|
||||||
loadSettings();
|
loadSettings();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showSettingsAlert('danger', 'Failed: ' + err.message);
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -543,9 +777,9 @@ document.getElementById('settings-images-form').addEventListener('submit', async
|
|||||||
netbird_relay_image: document.getElementById('cfg-relay-image').value,
|
netbird_relay_image: document.getElementById('cfg-relay-image').value,
|
||||||
netbird_dashboard_image: document.getElementById('cfg-dashboard-image').value,
|
netbird_dashboard_image: document.getElementById('cfg-dashboard-image').value,
|
||||||
});
|
});
|
||||||
showSettingsAlert('success', 'Image settings saved.');
|
showSettingsAlert('success', t('messages.imageSettingsSaved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showSettingsAlert('danger', 'Failed: ' + err.message);
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -579,7 +813,7 @@ document.getElementById('change-password-form').addEventListener('submit', async
|
|||||||
|
|
||||||
if (newPw !== confirmPw) {
|
if (newPw !== confirmPw) {
|
||||||
resultEl.className = 'mt-3 alert alert-danger';
|
resultEl.className = 'mt-3 alert alert-danger';
|
||||||
resultEl.textContent = 'Passwords do not match.';
|
resultEl.textContent = t('errors.passwordsDoNotMatch');
|
||||||
resultEl.classList.remove('d-none');
|
resultEl.classList.remove('d-none');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -590,7 +824,7 @@ document.getElementById('change-password-form').addEventListener('submit', async
|
|||||||
new_password: newPw,
|
new_password: newPw,
|
||||||
});
|
});
|
||||||
resultEl.className = 'mt-3 alert alert-success';
|
resultEl.className = 'mt-3 alert alert-success';
|
||||||
resultEl.textContent = 'Password changed successfully.';
|
resultEl.textContent = t('messages.passwordChanged');
|
||||||
resultEl.classList.remove('d-none');
|
resultEl.classList.remove('d-none');
|
||||||
document.getElementById('change-password-form').reset();
|
document.getElementById('change-password-form').reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -608,9 +842,193 @@ function showSettingsAlert(type, msg) {
|
|||||||
setTimeout(() => el.classList.add('d-none'), 5000);
|
setTimeout(() => el.classList.add('d-none'), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Branding form
|
||||||
|
document.getElementById('settings-branding-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api('PUT', '/settings/system', {
|
||||||
|
branding_name: document.getElementById('cfg-branding-name').value || 'NetBird MSP Appliance',
|
||||||
|
branding_subtitle: document.getElementById('cfg-branding-subtitle').value || 'Multi-Tenant Management Platform',
|
||||||
|
default_language: document.getElementById('cfg-default-language').value || 'en',
|
||||||
|
});
|
||||||
|
showSettingsAlert('success', t('messages.brandingNameSaved'));
|
||||||
|
await loadBranding();
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadLogo() {
|
||||||
|
const fileInput = document.getElementById('branding-logo-file');
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
showSettingsAlert('danger', t('errors.selectFileFirst'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/settings/branding/logo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${authToken}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.detail || t('errors.uploadFailed'));
|
||||||
|
}
|
||||||
|
updateLogoPreview(data.branding_logo_path);
|
||||||
|
showSettingsAlert('success', t('messages.logoUploaded'));
|
||||||
|
fileInput.value = '';
|
||||||
|
await loadBranding();
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsAlert('danger', t('errors.logoUploadFailed', { error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLogo() {
|
||||||
|
try {
|
||||||
|
await api('DELETE', '/settings/branding/logo');
|
||||||
|
updateLogoPreview(null);
|
||||||
|
showSettingsAlert('success', t('messages.logoRemoved'));
|
||||||
|
await loadBranding();
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsAlert('danger', t('errors.failedToRemoveLogo', { error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// User Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const users = await api('GET', '/users');
|
||||||
|
const tbody = document.getElementById('users-table-body');
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('settings.noUsersFound') || t('common.loading')}</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = users.map(u => {
|
||||||
|
const langDisplay = u.default_language ? u.default_language.toUpperCase() : `<span class="text-muted">${t('settings.systemDefault')}</span>`;
|
||||||
|
return `<tr>
|
||||||
|
<td>${u.id}</td>
|
||||||
|
<td><strong>${esc(u.username)}</strong></td>
|
||||||
|
<td>${esc(u.email || '-')}</td>
|
||||||
|
<td><span class="badge bg-info">${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>${langDisplay}</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>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
${u.is_active
|
||||||
|
? `<button class="btn btn-outline-warning" title="${t('common.disable')}" onclick="toggleUserActive(${u.id}, false)"><i class="bi bi-pause-circle"></i></button>`
|
||||||
|
: `<button class="btn btn-outline-success" title="${t('common.enable')}" onclick="toggleUserActive(${u.id}, true)"><i class="bi bi-play-circle"></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>` : ''}
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('users-table-body').innerHTML = `<tr><td colspan="8" class="text-danger">${err.message}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewUserModal() {
|
||||||
|
document.getElementById('user-form').reset();
|
||||||
|
document.getElementById('user-modal-error').classList.add('d-none');
|
||||||
|
new bootstrap.Modal(document.getElementById('user-modal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNewUser() {
|
||||||
|
const errorEl = document.getElementById('user-modal-error');
|
||||||
|
errorEl.classList.add('d-none');
|
||||||
|
|
||||||
|
const langValue = document.getElementById('new-user-language').value;
|
||||||
|
const payload = {
|
||||||
|
username: document.getElementById('new-user-username').value,
|
||||||
|
password: document.getElementById('new-user-password').value,
|
||||||
|
email: document.getElementById('new-user-email').value || null,
|
||||||
|
default_language: langValue || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('POST', '/users', payload);
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('user-modal')).hide();
|
||||||
|
showSettingsAlert('success', t('messages.userCreated', { username: payload.username }));
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id, username) {
|
||||||
|
if (!confirm(t('messages.confirmDeleteUser', { username }))) return;
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/users/${id}`);
|
||||||
|
showSettingsAlert('success', t('messages.userDeleted', { username }));
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsAlert('danger', t('errors.deleteFailed', { error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUserActive(id, active) {
|
||||||
|
try {
|
||||||
|
await api('PUT', `/users/${id}`, { is_active: active });
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsAlert('danger', t('errors.updateFailed', { error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetUserPassword(id, username) {
|
||||||
|
if (!confirm(t('messages.confirmResetPassword', { username }))) return;
|
||||||
|
try {
|
||||||
|
const data = await api('POST', `/users/${id}/reset-password`);
|
||||||
|
alert(t('messages.newPasswordAlert', { username, password: data.new_password }));
|
||||||
|
showSettingsAlert('success', t('messages.passwordResetFor', { username }));
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsAlert('danger', t('errors.passwordResetFailed', { error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Azure AD Settings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
document.getElementById('settings-azure-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
azure_enabled: document.getElementById('cfg-azure-enabled').checked,
|
||||||
|
azure_tenant_id: document.getElementById('cfg-azure-tenant').value || null,
|
||||||
|
azure_client_id: document.getElementById('cfg-azure-client-id').value || null,
|
||||||
|
};
|
||||||
|
const secret = document.getElementById('cfg-azure-client-secret').value;
|
||||||
|
if (secret) payload.azure_client_secret = secret;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('PUT', '/settings/system', payload);
|
||||||
|
showSettingsAlert('success', t('messages.azureSettingsSaved'));
|
||||||
|
document.getElementById('cfg-azure-client-secret').value = '';
|
||||||
|
loadSettings();
|
||||||
|
await loadAzureLoginConfig();
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function togglePasswordVisibility(inputId) {
|
function togglePasswordVisibility(inputId) {
|
||||||
const input = document.getElementById(inputId);
|
const input = document.getElementById(inputId);
|
||||||
input.type = input.type === 'password' ? 'text' : 'password';
|
if (!input) return;
|
||||||
|
const isHidden = input.type === 'password';
|
||||||
|
input.type = isHidden ? 'text' : 'password';
|
||||||
|
const btn = input.parentElement.querySelector('[data-toggle-pw]');
|
||||||
|
if (btn) {
|
||||||
|
const icon = btn.querySelector('i');
|
||||||
|
if (icon) icon.className = isHidden ? 'bi bi-eye-slash' : 'bi bi-eye';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -626,26 +1044,26 @@ async function loadResources() {
|
|||||||
document.getElementById('monitoring-resources').innerHTML = `
|
document.getElementById('monitoring-resources').innerHTML = `
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="text-muted small">Hostname</div>
|
<div class="text-muted small">${t('monitoring.hostname')}</div>
|
||||||
<div class="fw-bold">${esc(data.hostname)}</div>
|
<div class="fw-bold">${esc(data.hostname)}</div>
|
||||||
<div class="text-muted small">${esc(data.os)}</div>
|
<div class="text-muted small">${esc(data.os)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="text-muted small">CPU (${data.cpu.count} cores)</div>
|
<div class="text-muted small">${t('monitoring.cpu', { count: data.cpu.count })}</div>
|
||||||
<div class="progress mt-1" style="height: 20px;">
|
<div class="progress mt-1" style="height: 20px;">
|
||||||
<div class="progress-bar ${data.cpu.percent > 80 ? 'bg-danger' : data.cpu.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
<div class="progress-bar ${data.cpu.percent > 80 ? 'bg-danger' : data.cpu.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
||||||
style="width: ${data.cpu.percent}%">${data.cpu.percent}%</div>
|
style="width: ${data.cpu.percent}%">${data.cpu.percent}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="text-muted small">Memory (${data.memory.used_gb}/${data.memory.total_gb} GB)</div>
|
<div class="text-muted small">${t('monitoring.memory', { used: data.memory.used_gb, total: data.memory.total_gb })}</div>
|
||||||
<div class="progress mt-1" style="height: 20px;">
|
<div class="progress mt-1" style="height: 20px;">
|
||||||
<div class="progress-bar ${data.memory.percent > 80 ? 'bg-danger' : data.memory.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
<div class="progress-bar ${data.memory.percent > 80 ? 'bg-danger' : data.memory.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
||||||
style="width: ${data.memory.percent}%">${data.memory.percent}%</div>
|
style="width: ${data.memory.percent}%">${data.memory.percent}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="text-muted small">Disk (${data.disk.used_gb}/${data.disk.total_gb} GB)</div>
|
<div class="text-muted small">${t('monitoring.disk', { used: data.disk.used_gb, total: data.disk.total_gb })}</div>
|
||||||
<div class="progress mt-1" style="height: 20px;">
|
<div class="progress mt-1" style="height: 20px;">
|
||||||
<div class="progress-bar ${data.disk.percent > 80 ? 'bg-danger' : data.disk.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
<div class="progress-bar ${data.disk.percent > 80 ? 'bg-danger' : data.disk.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
||||||
style="width: ${data.disk.percent}%">${data.disk.percent}%</div>
|
style="width: ${data.disk.percent}%">${data.disk.percent}%</div>
|
||||||
@@ -663,23 +1081,28 @@ async function loadAllCustomerStatuses() {
|
|||||||
const data = await api('GET', '/monitoring/customers');
|
const data = await api('GET', '/monitoring/customers');
|
||||||
const tbody = document.getElementById('monitoring-customers-body');
|
const tbody = document.getElementById('monitoring-customers-body');
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No customers.</td></tr>';
|
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('monitoring.noCustomers')}</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = data.map(c => {
|
tbody.innerHTML = data.map(c => {
|
||||||
const containerInfo = c.containers.map(ct => `${ct.name}: ${ct.status}`).join(', ') || '-';
|
const containerInfo = c.containers.map(ct => `${ct.name}: ${ct.status}`).join(', ') || '-';
|
||||||
|
const dashPort = c.dashboard_port;
|
||||||
|
const dashLink = dashPort
|
||||||
|
? `<a href="${esc(c.setup_url || 'http://localhost:' + dashPort)}" target="_blank">:${dashPort}</a>`
|
||||||
|
: '-';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${c.id}</td>
|
<td>${c.id}</td>
|
||||||
<td>${esc(c.name)}</td>
|
<td>${esc(c.name)}</td>
|
||||||
<td><code>${esc(c.subdomain)}</code></td>
|
<td><code>${esc(c.subdomain)}</code></td>
|
||||||
<td>${statusBadge(c.status)}</td>
|
<td>${statusBadge(c.status)}</td>
|
||||||
<td>${c.deployment_status ? statusBadge(c.deployment_status) : '-'}</td>
|
<td>${c.deployment_status ? statusBadge(c.deployment_status) : '-'}</td>
|
||||||
|
<td>${dashLink}</td>
|
||||||
<td>${c.relay_udp_port || '-'}</td>
|
<td>${c.relay_udp_port || '-'}</td>
|
||||||
<td class="small">${esc(containerInfo)}</td>
|
<td class="small">${esc(containerInfo)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('monitoring-customers-body').innerHTML = `<tr><td colspan="7" class="text-danger">${err.message}</td></tr>`;
|
document.getElementById('monitoring-customers-body').innerHTML = `<tr><td colspan="8" class="text-danger">${err.message}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,8 +1122,9 @@ function statusBadge(status) {
|
|||||||
|
|
||||||
function formatDate(isoStr) {
|
function formatDate(isoStr) {
|
||||||
if (!isoStr) return '-';
|
if (!isoStr) return '-';
|
||||||
|
const locale = getCurrentLanguage() === 'de' ? 'de-DE' : 'en-US';
|
||||||
const d = new Date(isoStr);
|
const d = new Date(isoStr);
|
||||||
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(str) {
|
function esc(str) {
|
||||||
@@ -730,4 +1154,14 @@ function showToast(message) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
document.addEventListener('DOMContentLoaded', initApp);
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Check for Azure AD callback first
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.has('code')) {
|
||||||
|
await initI18n();
|
||||||
|
await loadBranding();
|
||||||
|
const handled = await handleAzureCallback();
|
||||||
|
if (handled) return;
|
||||||
|
}
|
||||||
|
initApp();
|
||||||
|
});
|
||||||
|
|||||||
100
static/js/i18n.js
Normal file
100
static/js/i18n.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* i18n - Internationalization for NetBird MSP Appliance
|
||||||
|
* Supports: English (en), German (de)
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentLanguage = null;
|
||||||
|
let systemDefaultLanguage = 'en';
|
||||||
|
const translations = {};
|
||||||
|
const SUPPORTED_LANGS = ['en', 'de'];
|
||||||
|
|
||||||
|
function setSystemDefault(lang) {
|
||||||
|
if (SUPPORTED_LANGS.includes(lang)) {
|
||||||
|
systemDefaultLanguage = lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLanguage() {
|
||||||
|
const stored = localStorage.getItem('language');
|
||||||
|
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
|
||||||
|
// Fall back to system default (from server settings)
|
||||||
|
if (systemDefaultLanguage && SUPPORTED_LANGS.includes(systemDefaultLanguage)) return systemDefaultLanguage;
|
||||||
|
const browser = (navigator.language || '').toLowerCase();
|
||||||
|
if (browser.startsWith('de')) return 'de';
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLanguage(lang) {
|
||||||
|
if (translations[lang]) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/static/lang/${lang}.json`);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
translations[lang] = await resp.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`i18n: failed to load ${lang}`, err);
|
||||||
|
if (lang !== 'en') await loadLanguage('en');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(key, params) {
|
||||||
|
const lang = currentLanguage || 'en';
|
||||||
|
const dict = translations[lang] || translations['en'] || {};
|
||||||
|
let value = key.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, dict);
|
||||||
|
if (value === null && lang !== 'en') {
|
||||||
|
const en = translations['en'] || {};
|
||||||
|
value = key.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, en);
|
||||||
|
}
|
||||||
|
if (value === null) return key;
|
||||||
|
if (params && typeof value === 'string') {
|
||||||
|
value = value.replace(/\{(\w+)\}/g, (m, p) => params[p] !== undefined ? params[p] : m);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTranslations() {
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
el.textContent = t(el.getAttribute('data-i18n'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||||
|
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
|
el.title = t(el.getAttribute('data-i18n-title'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-html]').forEach(el => {
|
||||||
|
el.innerHTML = t(el.getAttribute('data-i18n-html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLanguageSwitcher() {
|
||||||
|
const btn = document.getElementById('language-switcher-btn');
|
||||||
|
if (btn) btn.textContent = (currentLanguage || 'en').toUpperCase();
|
||||||
|
document.querySelectorAll('[data-lang]').forEach(el => {
|
||||||
|
el.classList.toggle('active', el.getAttribute('data-lang') === currentLanguage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLanguage(lang) {
|
||||||
|
if (!SUPPORTED_LANGS.includes(lang)) lang = 'en';
|
||||||
|
if (!translations[lang]) await loadLanguage(lang);
|
||||||
|
currentLanguage = lang;
|
||||||
|
localStorage.setItem('language', lang);
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
updateLanguageSwitcher();
|
||||||
|
applyTranslations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentLanguage() {
|
||||||
|
return currentLanguage || 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initI18n() {
|
||||||
|
const lang = detectLanguage();
|
||||||
|
await loadLanguage('en');
|
||||||
|
if (lang !== 'en') await loadLanguage(lang);
|
||||||
|
currentLanguage = lang;
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
updateLanguageSwitcher();
|
||||||
|
applyTranslations();
|
||||||
|
document.body.classList.remove('i18n-loading');
|
||||||
|
}
|
||||||
285
static/lang/de.json
Normal file
285
static/lang/de.json
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"subtitle": "Multi-Tenant Management Plattform",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"signInWithMicrosoft": "Mit Microsoft anmelden"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"newCustomer": "Neuer Kunde",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"monitoring": "Monitoring",
|
||||||
|
"logout": "Abmelden"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"totalCustomers": "Kunden gesamt",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"inactive": "Inaktiv",
|
||||||
|
"errors": "Fehler",
|
||||||
|
"searchPlaceholder": "Suche nach Name, Subdomain, E-Mail...",
|
||||||
|
"allStatuses": "Alle Status",
|
||||||
|
"statusActive": "Aktiv",
|
||||||
|
"statusInactive": "Inaktiv",
|
||||||
|
"statusDeploying": "Wird bereitgestellt",
|
||||||
|
"statusError": "Fehler",
|
||||||
|
"refresh": "Aktualisieren",
|
||||||
|
"thId": "ID",
|
||||||
|
"thName": "Name",
|
||||||
|
"thSubdomain": "Subdomain",
|
||||||
|
"thStatus": "Status",
|
||||||
|
"thDashboard": "Dashboard",
|
||||||
|
"thDevices": "Geraete",
|
||||||
|
"thCreated": "Erstellt",
|
||||||
|
"thActions": "Aktionen",
|
||||||
|
"noCustomers": "Keine Kunden gefunden. Klicken Sie auf \"Neuer Kunde\" um einen anzulegen.",
|
||||||
|
"showing": "Zeige {start}-{end} von {total}",
|
||||||
|
"showingEmpty": "Zeige 0 von 0"
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"back": "Zurueck",
|
||||||
|
"customer": "Kunde",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Loeschen",
|
||||||
|
"tabInfo": "Info",
|
||||||
|
"tabDeployment": "Deployment",
|
||||||
|
"tabLogs": "Logs",
|
||||||
|
"tabHealth": "Zustand",
|
||||||
|
"name": "Name:",
|
||||||
|
"company": "Firma:",
|
||||||
|
"subdomain": "Subdomain:",
|
||||||
|
"email": "E-Mail:",
|
||||||
|
"maxDevices": "Max. Geraete:",
|
||||||
|
"status": "Status:",
|
||||||
|
"created": "Erstellt:",
|
||||||
|
"updated": "Aktualisiert:",
|
||||||
|
"notes": "Notizen:",
|
||||||
|
"deploymentStatus": "Status:",
|
||||||
|
"relayUdpPort": "Relay UDP Port:",
|
||||||
|
"dashboardPort": "Dashboard Port:",
|
||||||
|
"containerPrefix": "Container-Praefix:",
|
||||||
|
"deployed": "Bereitgestellt:",
|
||||||
|
"setupUrl": "Setup URL:",
|
||||||
|
"copy": "Kopieren",
|
||||||
|
"open": "Oeffnen",
|
||||||
|
"netbirdLogin": "NetBird Login",
|
||||||
|
"notAvailable": "Nicht verfuegbar",
|
||||||
|
"showCredentials": "Zugangsdaten anzeigen",
|
||||||
|
"credEmail": "E-Mail",
|
||||||
|
"credPassword": "Passwort",
|
||||||
|
"showHide": "Anzeigen/Verbergen",
|
||||||
|
"credentialsNotAvailable": "Zugangsdaten nicht verfuegbar. Der Admin muss das Setup manuell ueber die Setup URL abschliessen.",
|
||||||
|
"start": "Starten",
|
||||||
|
"stop": "Stoppen",
|
||||||
|
"restart": "Neustarten",
|
||||||
|
"reDeploy": "Neu bereitstellen",
|
||||||
|
"noDeployment": "Kein Deployment gefunden.",
|
||||||
|
"deployNow": "Jetzt bereitstellen",
|
||||||
|
"containerLogs": "Container Logs",
|
||||||
|
"noContainerLogs": "Keine Container-Logs verfuegbar.",
|
||||||
|
"noLogsLoaded": "Keine Logs geladen.",
|
||||||
|
"healthCheck": "Zustandspruefung",
|
||||||
|
"check": "Pruefen",
|
||||||
|
"clickCheck": "Klicken Sie auf \"Pruefen\" um eine Zustandspruefung durchzufuehren.",
|
||||||
|
"healthy": "Gesund",
|
||||||
|
"unhealthy": "Fehlerhaft",
|
||||||
|
"overall": "Gesamt:",
|
||||||
|
"thContainer": "Container",
|
||||||
|
"thContainerStatus": "Status",
|
||||||
|
"thHealth": "Zustand",
|
||||||
|
"thImage": "Image",
|
||||||
|
"lastCheck": "Letzte Pruefung: {time}",
|
||||||
|
"openDashboard": "Dashboard oeffnen"
|
||||||
|
},
|
||||||
|
"customerModal": {
|
||||||
|
"newCustomer": "Neuer Kunde",
|
||||||
|
"editCustomer": "Kunde bearbeiten",
|
||||||
|
"nameLabel": "Name *",
|
||||||
|
"companyLabel": "Firma",
|
||||||
|
"subdomainLabel": "Subdomain *",
|
||||||
|
"subdomainHint": "Kleinbuchstaben, alphanumerisch + Bindestriche",
|
||||||
|
"emailLabel": "E-Mail *",
|
||||||
|
"maxDevicesLabel": "Max. Geraete",
|
||||||
|
"notesLabel": "Notizen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"saveAndDeploy": "Speichern & Bereitstellen",
|
||||||
|
"saveChanges": "Aenderungen speichern"
|
||||||
|
},
|
||||||
|
"deleteModal": {
|
||||||
|
"title": "Loeschung bestaetigen",
|
||||||
|
"confirmText": "Sind Sie sicher, dass Sie den Kunden loeschen moechten",
|
||||||
|
"warning": "Alle Container, NPM-Eintraege und Daten werden entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden.",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Loeschen"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Systemeinstellungen",
|
||||||
|
"tabSystem": "Systemkonfiguration",
|
||||||
|
"tabNpm": "NPM Integration",
|
||||||
|
"tabImages": "Docker Images",
|
||||||
|
"tabBranding": "Branding",
|
||||||
|
"tabUsers": "Benutzer",
|
||||||
|
"tabAzure": "Azure AD",
|
||||||
|
"tabSecurity": "Sicherheit",
|
||||||
|
"baseDomain": "Basis-Domain",
|
||||||
|
"baseDomainPlaceholder": "ihredomain.com",
|
||||||
|
"baseDomainHint": "Kunden erhalten Subdomains: kunde.ihredomain.com",
|
||||||
|
"adminEmail": "Admin E-Mail",
|
||||||
|
"adminEmailPlaceholder": "admin@ihredomain.com",
|
||||||
|
"dataDir": "Datenverzeichnis",
|
||||||
|
"dataDirPlaceholder": "/opt/netbird-instances",
|
||||||
|
"dockerNetwork": "Docker Netzwerk",
|
||||||
|
"dockerNetworkPlaceholder": "npm-network",
|
||||||
|
"relayBasePort": "Relay Basis-Port",
|
||||||
|
"relayBasePortHint": "Erster UDP-Port fuer Relay. Bereich: Basis bis Basis+99",
|
||||||
|
"dashboardBasePort": "Dashboard Basis-Port",
|
||||||
|
"dashboardBasePortHint": "Basis-Port fuer Kunden-Dashboards. Kunde N erhaelt Basis+N",
|
||||||
|
"saveSystemSettings": "Systemeinstellungen speichern",
|
||||||
|
"npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Anmeldedaten (E-Mail + Passwort) ein. Das System meldet sich automatisch an und bezieht Tokens fuer API-Aufrufe.",
|
||||||
|
"npmApiUrl": "NPM API URL",
|
||||||
|
"npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api",
|
||||||
|
"npmApiUrlHint": "http:// oder https:// - muss /api am Ende enthalten",
|
||||||
|
"npmLoginEmail": "NPM Login E-Mail",
|
||||||
|
"npmLoginEmailPlaceholder": "Leer lassen um aktuelle beizubehalten",
|
||||||
|
"npmLoginPassword": "NPM Login Passwort",
|
||||||
|
"npmLoginPasswordPlaceholder": "Leer lassen um aktuelles beizubehalten",
|
||||||
|
"credentialsSet": "Zugangsdaten sind gesetzt (leer lassen um aktuelle beizubehalten)",
|
||||||
|
"noCredentials": "Keine NPM-Zugangsdaten konfiguriert",
|
||||||
|
"saveNpmSettings": "NPM Einstellungen speichern",
|
||||||
|
"testConnection": "Verbindung testen",
|
||||||
|
"managementImage": "Management Image",
|
||||||
|
"managementImagePlaceholder": "netbirdio/management:latest",
|
||||||
|
"signalImage": "Signal Image",
|
||||||
|
"signalImagePlaceholder": "netbirdio/signal:latest",
|
||||||
|
"relayImage": "Relay Image",
|
||||||
|
"relayImagePlaceholder": "netbirdio/relay:latest",
|
||||||
|
"dashboardImage": "Dashboard Image",
|
||||||
|
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
|
||||||
|
"saveImageSettings": "Image Einstellungen speichern",
|
||||||
|
"brandingTitle": "Branding Einstellungen",
|
||||||
|
"companyName": "Firmen- / Anwendungsname",
|
||||||
|
"companyNamePlaceholder": "NetBird MSP Appliance",
|
||||||
|
"companyNameHint": "Wird auf der Login-Seite und in der Navbar angezeigt",
|
||||||
|
"logoPreview": "Logo-Vorschau",
|
||||||
|
"defaultIcon": "Standard-Icon (kein Logo hochgeladen)",
|
||||||
|
"uploadLogo": "Logo hochladen (PNG, JPG, SVG, max 500KB)",
|
||||||
|
"uploadBtn": "Hochladen",
|
||||||
|
"removeLogo": "Logo entfernen",
|
||||||
|
"brandingSubtitle": "Untertitel",
|
||||||
|
"brandingSubtitlePlaceholder": "Multi-Tenant Management Plattform",
|
||||||
|
"brandingSubtitleHint": "Wird unter dem Titel auf der Login-Seite angezeigt",
|
||||||
|
"defaultLanguage": "Standardsprache",
|
||||||
|
"defaultLanguageHint": "Standardsprache fuer Benutzer ohne eigene Einstellung",
|
||||||
|
"systemDefault": "Systemstandard",
|
||||||
|
"saveBranding": "Branding speichern",
|
||||||
|
"userManagement": "Benutzerverwaltung",
|
||||||
|
"newUser": "Neuer Benutzer",
|
||||||
|
"thId": "ID",
|
||||||
|
"thUsername": "Benutzername",
|
||||||
|
"thEmail": "E-Mail",
|
||||||
|
"thRole": "Rolle",
|
||||||
|
"thAuth": "Auth",
|
||||||
|
"thLanguage": "Sprache",
|
||||||
|
"thStatus": "Status",
|
||||||
|
"thActions": "Aktionen",
|
||||||
|
"azureTitle": "Azure AD / Entra ID Integration",
|
||||||
|
"enableAzureSso": "Azure AD SSO aktivieren",
|
||||||
|
"tenantId": "Tenant ID",
|
||||||
|
"clientId": "Client ID (Anwendungs-ID)",
|
||||||
|
"clientSecret": "Client Secret",
|
||||||
|
"clientSecretPlaceholder": "Leer lassen um aktuelles beizubehalten",
|
||||||
|
"secretSet": "Secret ist gesetzt (leer lassen um aktuelles beizubehalten)",
|
||||||
|
"noSecret": "Kein Client Secret konfiguriert",
|
||||||
|
"saveAzureSettings": "Azure AD Einstellungen speichern",
|
||||||
|
"securityTitle": "Admin-Passwort aendern",
|
||||||
|
"currentPassword": "Aktuelles Passwort",
|
||||||
|
"newPassword": "Neues Passwort (min. 12 Zeichen)",
|
||||||
|
"confirmPassword": "Neues Passwort bestaetigen",
|
||||||
|
"changePassword": "Passwort aendern"
|
||||||
|
},
|
||||||
|
"monitoring": {
|
||||||
|
"title": "System Monitoring",
|
||||||
|
"refresh": "Aktualisieren",
|
||||||
|
"hostResources": "Host-Ressourcen",
|
||||||
|
"hostname": "Hostname",
|
||||||
|
"cpu": "CPU ({count} Kerne)",
|
||||||
|
"memory": "Speicher ({used}/{total} GB)",
|
||||||
|
"disk": "Festplatte ({used}/{total} GB)",
|
||||||
|
"allCustomerDeployments": "Alle Kunden-Deployments",
|
||||||
|
"thId": "ID",
|
||||||
|
"thName": "Name",
|
||||||
|
"thSubdomain": "Subdomain",
|
||||||
|
"thStatus": "Status",
|
||||||
|
"thDeployment": "Deployment",
|
||||||
|
"thDashboard": "Dashboard",
|
||||||
|
"thRelayPort": "Relay Port",
|
||||||
|
"thContainers": "Container",
|
||||||
|
"noCustomers": "Keine Kunden."
|
||||||
|
},
|
||||||
|
"userModal": {
|
||||||
|
"title": "Neuer Benutzer",
|
||||||
|
"usernameLabel": "Benutzername *",
|
||||||
|
"passwordLabel": "Passwort * (min. 8 Zeichen)",
|
||||||
|
"emailLabel": "E-Mail",
|
||||||
|
"languageLabel": "Standardsprache",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"createUser": "Benutzer erstellen"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Laden...",
|
||||||
|
"back": "Zurueck",
|
||||||
|
"save": "Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Loeschen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"view": "Anzeigen",
|
||||||
|
"start": "Starten",
|
||||||
|
"stop": "Stoppen",
|
||||||
|
"restart": "Neustarten",
|
||||||
|
"disable": "Deaktivieren",
|
||||||
|
"enable": "Aktivieren",
|
||||||
|
"resetPassword": "Passwort zuruecksetzen",
|
||||||
|
"open": "Oeffnen",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"disabled": "Deaktiviert"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"networkError": "Netzwerkfehler \u2014 Server nicht erreichbar.",
|
||||||
|
"sessionExpired": "Sitzung abgelaufen.",
|
||||||
|
"requestFailed": "Anfrage fehlgeschlagen.",
|
||||||
|
"serverError": "Serverfehler (HTTP {status}).",
|
||||||
|
"unknownError": "Ein unbekannter Fehler ist aufgetreten.",
|
||||||
|
"uploadFailed": "Upload fehlgeschlagen.",
|
||||||
|
"deleteFailed": "Loeschen fehlgeschlagen: {error}",
|
||||||
|
"failedToLoadSettings": "Einstellungen konnten nicht geladen werden: {error}",
|
||||||
|
"failed": "Fehlgeschlagen: {error}",
|
||||||
|
"logoUploadFailed": "Logo-Upload fehlgeschlagen: {error}",
|
||||||
|
"failedToRemoveLogo": "Logo konnte nicht entfernt werden: {error}",
|
||||||
|
"updateFailed": "Aktualisierung fehlgeschlagen: {error}",
|
||||||
|
"passwordResetFailed": "Passwort-Zuruecksetzung fehlgeschlagen: {error}",
|
||||||
|
"selectFileFirst": "Bitte waehlen Sie zuerst eine Datei aus.",
|
||||||
|
"passwordsDoNotMatch": "Passwoerter stimmen nicht ueberein.",
|
||||||
|
"failedToLoadCredentials": "Zugangsdaten konnten nicht geladen werden: {error}",
|
||||||
|
"azureNotConfigured": "Azure AD ist nicht konfiguriert.",
|
||||||
|
"azureLoginFailed": "Azure AD Anmeldung fehlgeschlagen: {error}",
|
||||||
|
"actionFailed": "{action} fehlgeschlagen: {error}"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"systemSettingsSaved": "Systemeinstellungen gespeichert.",
|
||||||
|
"npmSettingsSaved": "NPM Einstellungen gespeichert.",
|
||||||
|
"imageSettingsSaved": "Image Einstellungen gespeichert.",
|
||||||
|
"brandingNameSaved": "Branding-Einstellungen gespeichert.",
|
||||||
|
"logoUploaded": "Logo erfolgreich hochgeladen.",
|
||||||
|
"logoRemoved": "Logo entfernt.",
|
||||||
|
"azureSettingsSaved": "Azure AD Einstellungen gespeichert.",
|
||||||
|
"passwordChanged": "Passwort erfolgreich geaendert.",
|
||||||
|
"setupUrlCopied": "Setup URL in die Zwischenablage kopiert.",
|
||||||
|
"copiedToClipboard": "In die Zwischenablage kopiert.",
|
||||||
|
"userCreated": "Benutzer '{username}' erstellt.",
|
||||||
|
"userDeleted": "Benutzer '{username}' geloescht.",
|
||||||
|
"passwordResetFor": "Passwort fuer '{username}' zurueckgesetzt.",
|
||||||
|
"newPasswordAlert": "Neues Passwort fuer '{username}':\n\n{password}\n\nBitte speichern Sie dieses Passwort jetzt. Es wird nicht erneut angezeigt.",
|
||||||
|
"confirmDeleteUser": "Benutzer '{username}' loeschen? Dies kann nicht rueckgaengig gemacht werden.",
|
||||||
|
"confirmResetPassword": "Passwort fuer '{username}' zuruecksetzen? Ein neues zufaelliges Passwort wird generiert."
|
||||||
|
}
|
||||||
|
}
|
||||||
285
static/lang/en.json
Normal file
285
static/lang/en.json
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"subtitle": "Multi-Tenant Management Platform",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"signIn": "Sign In",
|
||||||
|
"signInWithMicrosoft": "Sign in with Microsoft"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"newCustomer": "New Customer",
|
||||||
|
"settings": "Settings",
|
||||||
|
"monitoring": "Monitoring",
|
||||||
|
"logout": "Logout"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"totalCustomers": "Total Customers",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"errors": "Errors",
|
||||||
|
"searchPlaceholder": "Search by name, subdomain, email...",
|
||||||
|
"allStatuses": "All Statuses",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusInactive": "Inactive",
|
||||||
|
"statusDeploying": "Deploying",
|
||||||
|
"statusError": "Error",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"thId": "ID",
|
||||||
|
"thName": "Name",
|
||||||
|
"thSubdomain": "Subdomain",
|
||||||
|
"thStatus": "Status",
|
||||||
|
"thDashboard": "Dashboard",
|
||||||
|
"thDevices": "Devices",
|
||||||
|
"thCreated": "Created",
|
||||||
|
"thActions": "Actions",
|
||||||
|
"noCustomers": "No customers found. Click \"New Customer\" to create one.",
|
||||||
|
"showing": "Showing {start}-{end} of {total}",
|
||||||
|
"showingEmpty": "Showing 0 of 0"
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"back": "Back",
|
||||||
|
"customer": "Customer",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"tabInfo": "Info",
|
||||||
|
"tabDeployment": "Deployment",
|
||||||
|
"tabLogs": "Logs",
|
||||||
|
"tabHealth": "Health",
|
||||||
|
"name": "Name:",
|
||||||
|
"company": "Company:",
|
||||||
|
"subdomain": "Subdomain:",
|
||||||
|
"email": "Email:",
|
||||||
|
"maxDevices": "Max Devices:",
|
||||||
|
"status": "Status:",
|
||||||
|
"created": "Created:",
|
||||||
|
"updated": "Updated:",
|
||||||
|
"notes": "Notes:",
|
||||||
|
"deploymentStatus": "Status:",
|
||||||
|
"relayUdpPort": "Relay UDP Port:",
|
||||||
|
"dashboardPort": "Dashboard Port:",
|
||||||
|
"containerPrefix": "Container Prefix:",
|
||||||
|
"deployed": "Deployed:",
|
||||||
|
"setupUrl": "Setup URL:",
|
||||||
|
"copy": "Copy",
|
||||||
|
"open": "Open",
|
||||||
|
"netbirdLogin": "NetBird Login",
|
||||||
|
"notAvailable": "Not available",
|
||||||
|
"showCredentials": "Show Credentials",
|
||||||
|
"credEmail": "Email",
|
||||||
|
"credPassword": "Password",
|
||||||
|
"showHide": "Show/Hide",
|
||||||
|
"credentialsNotAvailable": "Credentials not available. Admin must complete setup manually at the Setup URL.",
|
||||||
|
"start": "Start",
|
||||||
|
"stop": "Stop",
|
||||||
|
"restart": "Restart",
|
||||||
|
"reDeploy": "Re-Deploy",
|
||||||
|
"noDeployment": "No deployment found.",
|
||||||
|
"deployNow": "Deploy Now",
|
||||||
|
"containerLogs": "Container Logs",
|
||||||
|
"noContainerLogs": "No container logs available.",
|
||||||
|
"noLogsLoaded": "No logs loaded.",
|
||||||
|
"healthCheck": "Health Check",
|
||||||
|
"check": "Check",
|
||||||
|
"clickCheck": "Click \"Check\" to run a health check.",
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"unhealthy": "Unhealthy",
|
||||||
|
"overall": "Overall:",
|
||||||
|
"thContainer": "Container",
|
||||||
|
"thContainerStatus": "Status",
|
||||||
|
"thHealth": "Health",
|
||||||
|
"thImage": "Image",
|
||||||
|
"lastCheck": "Last check: {time}",
|
||||||
|
"openDashboard": "Open Dashboard"
|
||||||
|
},
|
||||||
|
"customerModal": {
|
||||||
|
"newCustomer": "New Customer",
|
||||||
|
"editCustomer": "Edit Customer",
|
||||||
|
"nameLabel": "Name *",
|
||||||
|
"companyLabel": "Company",
|
||||||
|
"subdomainLabel": "Subdomain *",
|
||||||
|
"subdomainHint": "Lowercase, alphanumeric + hyphens",
|
||||||
|
"emailLabel": "Email *",
|
||||||
|
"maxDevicesLabel": "Max Devices",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saveAndDeploy": "Save & Deploy",
|
||||||
|
"saveChanges": "Save Changes"
|
||||||
|
},
|
||||||
|
"deleteModal": {
|
||||||
|
"title": "Confirm Deletion",
|
||||||
|
"confirmText": "Are you sure you want to delete customer",
|
||||||
|
"warning": "This will remove all containers, NPM entries, and data. This action cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "System Settings",
|
||||||
|
"tabSystem": "System Configuration",
|
||||||
|
"tabNpm": "NPM Integration",
|
||||||
|
"tabImages": "Docker Images",
|
||||||
|
"tabBranding": "Branding",
|
||||||
|
"tabUsers": "Users",
|
||||||
|
"tabAzure": "Azure AD",
|
||||||
|
"tabSecurity": "Security",
|
||||||
|
"baseDomain": "Base Domain",
|
||||||
|
"baseDomainPlaceholder": "yourdomain.com",
|
||||||
|
"baseDomainHint": "Customers get subdomains: customer.yourdomain.com",
|
||||||
|
"adminEmail": "Admin Email",
|
||||||
|
"adminEmailPlaceholder": "admin@yourdomain.com",
|
||||||
|
"dataDir": "Data Directory",
|
||||||
|
"dataDirPlaceholder": "/opt/netbird-instances",
|
||||||
|
"dockerNetwork": "Docker Network",
|
||||||
|
"dockerNetworkPlaceholder": "npm-network",
|
||||||
|
"relayBasePort": "Relay Base Port",
|
||||||
|
"relayBasePortHint": "First UDP port for relay. Range: base to base+99",
|
||||||
|
"dashboardBasePort": "Dashboard Base Port",
|
||||||
|
"dashboardBasePortHint": "Base port for customer dashboards. Customer N gets base+N",
|
||||||
|
"saveSystemSettings": "Save System Settings",
|
||||||
|
"npmDescription": "NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.",
|
||||||
|
"npmApiUrl": "NPM API URL",
|
||||||
|
"npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api",
|
||||||
|
"npmApiUrlHint": "http:// or https:// - must include /api at the end",
|
||||||
|
"npmLoginEmail": "NPM Login Email",
|
||||||
|
"npmLoginEmailPlaceholder": "Leave empty to keep current",
|
||||||
|
"npmLoginPassword": "NPM Login Password",
|
||||||
|
"npmLoginPasswordPlaceholder": "Leave empty to keep current",
|
||||||
|
"credentialsSet": "Credentials are set (leave empty to keep current)",
|
||||||
|
"noCredentials": "No NPM credentials configured",
|
||||||
|
"saveNpmSettings": "Save NPM Settings",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"managementImage": "Management Image",
|
||||||
|
"managementImagePlaceholder": "netbirdio/management:latest",
|
||||||
|
"signalImage": "Signal Image",
|
||||||
|
"signalImagePlaceholder": "netbirdio/signal:latest",
|
||||||
|
"relayImage": "Relay Image",
|
||||||
|
"relayImagePlaceholder": "netbirdio/relay:latest",
|
||||||
|
"dashboardImage": "Dashboard Image",
|
||||||
|
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
|
||||||
|
"saveImageSettings": "Save Image Settings",
|
||||||
|
"brandingTitle": "Branding Settings",
|
||||||
|
"companyName": "Company / Application Name",
|
||||||
|
"companyNamePlaceholder": "NetBird MSP Appliance",
|
||||||
|
"companyNameHint": "Displayed on login page and navbar",
|
||||||
|
"logoPreview": "Logo Preview",
|
||||||
|
"defaultIcon": "Default icon (no logo uploaded)",
|
||||||
|
"uploadLogo": "Upload Logo (PNG, JPG, SVG, max 500KB)",
|
||||||
|
"uploadBtn": "Upload",
|
||||||
|
"removeLogo": "Remove Logo",
|
||||||
|
"brandingSubtitle": "Subtitle",
|
||||||
|
"brandingSubtitlePlaceholder": "Multi-Tenant Management Platform",
|
||||||
|
"brandingSubtitleHint": "Shown below the title on the login page",
|
||||||
|
"defaultLanguage": "Default Language",
|
||||||
|
"defaultLanguageHint": "Default language for users without a preference",
|
||||||
|
"systemDefault": "System Default",
|
||||||
|
"saveBranding": "Save Branding",
|
||||||
|
"userManagement": "User Management",
|
||||||
|
"newUser": "New User",
|
||||||
|
"thId": "ID",
|
||||||
|
"thUsername": "Username",
|
||||||
|
"thEmail": "Email",
|
||||||
|
"thRole": "Role",
|
||||||
|
"thAuth": "Auth",
|
||||||
|
"thLanguage": "Language",
|
||||||
|
"thStatus": "Status",
|
||||||
|
"thActions": "Actions",
|
||||||
|
"azureTitle": "Azure AD / Entra ID Integration",
|
||||||
|
"enableAzureSso": "Enable Azure AD SSO",
|
||||||
|
"tenantId": "Tenant ID",
|
||||||
|
"clientId": "Client ID (Application ID)",
|
||||||
|
"clientSecret": "Client Secret",
|
||||||
|
"clientSecretPlaceholder": "Leave empty to keep current",
|
||||||
|
"secretSet": "Secret is set (leave empty to keep current)",
|
||||||
|
"noSecret": "No client secret configured",
|
||||||
|
"saveAzureSettings": "Save Azure AD Settings",
|
||||||
|
"securityTitle": "Change Admin Password",
|
||||||
|
"currentPassword": "Current Password",
|
||||||
|
"newPassword": "New Password (min 12 chars)",
|
||||||
|
"confirmPassword": "Confirm New Password",
|
||||||
|
"changePassword": "Change Password"
|
||||||
|
},
|
||||||
|
"monitoring": {
|
||||||
|
"title": "System Monitoring",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"hostResources": "Host Resources",
|
||||||
|
"hostname": "Hostname",
|
||||||
|
"cpu": "CPU ({count} cores)",
|
||||||
|
"memory": "Memory ({used}/{total} GB)",
|
||||||
|
"disk": "Disk ({used}/{total} GB)",
|
||||||
|
"allCustomerDeployments": "All Customer Deployments",
|
||||||
|
"thId": "ID",
|
||||||
|
"thName": "Name",
|
||||||
|
"thSubdomain": "Subdomain",
|
||||||
|
"thStatus": "Status",
|
||||||
|
"thDeployment": "Deployment",
|
||||||
|
"thDashboard": "Dashboard",
|
||||||
|
"thRelayPort": "Relay Port",
|
||||||
|
"thContainers": "Containers",
|
||||||
|
"noCustomers": "No customers."
|
||||||
|
},
|
||||||
|
"userModal": {
|
||||||
|
"title": "New User",
|
||||||
|
"usernameLabel": "Username *",
|
||||||
|
"passwordLabel": "Password * (min 8 chars)",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"languageLabel": "Default Language",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"createUser": "Create User"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"back": "Back",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"view": "View",
|
||||||
|
"start": "Start",
|
||||||
|
"stop": "Stop",
|
||||||
|
"restart": "Restart",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enable": "Enable",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"open": "Open",
|
||||||
|
"active": "Active",
|
||||||
|
"disabled": "Disabled"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"networkError": "Network error \u2014 server not reachable.",
|
||||||
|
"sessionExpired": "Session expired.",
|
||||||
|
"requestFailed": "Request failed.",
|
||||||
|
"serverError": "Server error (HTTP {status}).",
|
||||||
|
"unknownError": "An unknown error occurred.",
|
||||||
|
"uploadFailed": "Upload failed.",
|
||||||
|
"deleteFailed": "Delete failed: {error}",
|
||||||
|
"failedToLoadSettings": "Failed to load settings: {error}",
|
||||||
|
"failed": "Failed: {error}",
|
||||||
|
"logoUploadFailed": "Logo upload failed: {error}",
|
||||||
|
"failedToRemoveLogo": "Failed to remove logo: {error}",
|
||||||
|
"updateFailed": "Update failed: {error}",
|
||||||
|
"passwordResetFailed": "Password reset failed: {error}",
|
||||||
|
"selectFileFirst": "Please select a file first.",
|
||||||
|
"passwordsDoNotMatch": "Passwords do not match.",
|
||||||
|
"failedToLoadCredentials": "Failed to load credentials: {error}",
|
||||||
|
"azureNotConfigured": "Azure AD is not configured.",
|
||||||
|
"azureLoginFailed": "Azure AD login failed: {error}",
|
||||||
|
"actionFailed": "{action} failed: {error}"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"systemSettingsSaved": "System settings saved.",
|
||||||
|
"npmSettingsSaved": "NPM settings saved.",
|
||||||
|
"imageSettingsSaved": "Image settings saved.",
|
||||||
|
"brandingNameSaved": "Branding settings saved.",
|
||||||
|
"logoUploaded": "Logo uploaded successfully.",
|
||||||
|
"logoRemoved": "Logo removed.",
|
||||||
|
"azureSettingsSaved": "Azure AD settings saved.",
|
||||||
|
"passwordChanged": "Password changed successfully.",
|
||||||
|
"setupUrlCopied": "Setup URL copied to clipboard.",
|
||||||
|
"copiedToClipboard": "Copied to clipboard.",
|
||||||
|
"userCreated": "User '{username}' created.",
|
||||||
|
"userDeleted": "User '{username}' deleted.",
|
||||||
|
"passwordResetFor": "Password reset for '{username}'.",
|
||||||
|
"newPasswordAlert": "New password for '{username}':\n\n{password}\n\nPlease save this password now. It will not be shown again.",
|
||||||
|
"confirmDeleteUser": "Delete user '{username}'? This cannot be undone.",
|
||||||
|
"confirmResetPassword": "Reset password for '{username}'? A new random password will be generated."
|
||||||
|
}
|
||||||
|
}
|
||||||
36
templates/Caddyfile.j2
Normal file
36
templates/Caddyfile.j2
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
# Embedded IdP OAuth2/OIDC endpoints
|
||||||
|
handle /oauth2/* {
|
||||||
|
reverse_proxy netbird-kunde{{ customer_id }}-management:80
|
||||||
|
}
|
||||||
|
|
||||||
|
# NetBird Management API + gRPC
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy netbird-kunde{{ customer_id }}-management:80
|
||||||
|
}
|
||||||
|
handle /management.ManagementService/* {
|
||||||
|
reverse_proxy netbird-kunde{{ customer_id }}-management:80 {
|
||||||
|
transport http {
|
||||||
|
versions h2c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# NetBird Signal gRPC
|
||||||
|
handle /signalexchange.SignalExchange/* {
|
||||||
|
reverse_proxy netbird-kunde{{ customer_id }}-signal:80 {
|
||||||
|
transport http {
|
||||||
|
versions h2c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default: NetBird Dashboard
|
||||||
|
handle {
|
||||||
|
reverse_proxy netbird-kunde{{ customer_id }}-dashboard:80
|
||||||
|
}
|
||||||
|
}
|
||||||
13
templates/dashboard.env.j2
Normal file
13
templates/dashboard.env.j2
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# NetBird Dashboard - Customer {{ customer_id }}
|
||||||
|
NETBIRD_MGMT_API_ENDPOINT={{ external_url }}
|
||||||
|
NETBIRD_MGMT_GRPC_API_ENDPOINT={{ external_url }}
|
||||||
|
AUTH_AUTHORITY={{ external_url }}/oauth2
|
||||||
|
AUTH_CLIENT_ID=netbird-dashboard
|
||||||
|
AUTH_AUDIENCE=netbird-dashboard
|
||||||
|
AUTH_SUPPORTED_SCOPES=openid profile email groups offline_access
|
||||||
|
AUTH_REDIRECT_URI=/nb-auth
|
||||||
|
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||||
|
USE_AUTH0=false
|
||||||
|
NETBIRD_TOKEN_SOURCE=accessToken
|
||||||
|
LETSENCRYPT_DOMAIN=none
|
||||||
|
NGINX_SSL_PORT=443
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
{{ docker_network }}:
|
{{ docker_network }}:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# --- Caddy Reverse Proxy (entry point) ---
|
||||||
|
netbird-caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: netbird-kunde{{ customer_id }}-caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- {{ docker_network }}
|
||||||
|
ports:
|
||||||
|
- "{{ dashboard_port }}:80"
|
||||||
|
volumes:
|
||||||
|
- {{ instance_dir }}/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
|
||||||
|
# --- NetBird Management (with embedded IdP) ---
|
||||||
netbird-management:
|
netbird-management:
|
||||||
image: {{ netbird_management_image }}
|
image: {{ netbird_management_image }}
|
||||||
container_name: netbird-kunde{{ customer_id }}-management
|
container_name: netbird-kunde{{ customer_id }}-management
|
||||||
@@ -21,15 +32,11 @@ services:
|
|||||||
- "console"
|
- "console"
|
||||||
- "--log-level"
|
- "--log-level"
|
||||||
- "info"
|
- "info"
|
||||||
- "--single-account-mode-domain={{ subdomain }}.{{ base_domain }}"
|
- "--single-account-mode-domain={{ netbird_domain }}"
|
||||||
- "--dns-domain={{ subdomain }}.{{ base_domain }}"
|
- "--dns-domain={{ netbird_domain }}"
|
||||||
healthcheck:
|
- "--idp-sign-key-refresh-enabled"
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/api/accounts"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
|
|
||||||
|
# --- NetBird Signal ---
|
||||||
netbird-signal:
|
netbird-signal:
|
||||||
image: {{ netbird_signal_image }}
|
image: {{ netbird_signal_image }}
|
||||||
container_name: netbird-kunde{{ customer_id }}-signal
|
container_name: netbird-kunde{{ customer_id }}-signal
|
||||||
@@ -39,6 +46,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- {{ instance_dir }}/data/signal:/var/lib/netbird
|
- {{ instance_dir }}/data/signal:/var/lib/netbird
|
||||||
|
|
||||||
|
# --- NetBird Relay ---
|
||||||
netbird-relay:
|
netbird-relay:
|
||||||
image: {{ netbird_relay_image }}
|
image: {{ netbird_relay_image }}
|
||||||
container_name: netbird-kunde{{ customer_id }}-relay
|
container_name: netbird-kunde{{ customer_id }}-relay
|
||||||
@@ -49,19 +57,13 @@ services:
|
|||||||
- "{{ relay_udp_port }}:3478/udp"
|
- "{{ relay_udp_port }}:3478/udp"
|
||||||
env_file:
|
env_file:
|
||||||
- {{ instance_dir }}/relay.env
|
- {{ instance_dir }}/relay.env
|
||||||
environment:
|
|
||||||
- NB_ENABLE_STUN=true
|
|
||||||
- NB_STUN_PORTS=3478
|
|
||||||
- NB_LISTEN_ADDRESS=:80
|
|
||||||
- NB_EXPOSED_ADDRESS=rels://{{ subdomain }}.{{ base_domain }}:443
|
|
||||||
- NB_AUTH_SECRET={{ relay_secret }}
|
|
||||||
|
|
||||||
|
# --- NetBird Dashboard ---
|
||||||
netbird-dashboard:
|
netbird-dashboard:
|
||||||
image: {{ netbird_dashboard_image }}
|
image: {{ netbird_dashboard_image }}
|
||||||
container_name: netbird-kunde{{ customer_id }}-dashboard
|
container_name: netbird-kunde{{ customer_id }}-dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- {{ docker_network }}
|
- {{ docker_network }}
|
||||||
environment:
|
env_file:
|
||||||
- NETBIRD_MGMT_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }}
|
- {{ instance_dir }}/dashboard.env
|
||||||
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://{{ subdomain }}.{{ base_domain }}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"Stuns": [
|
"Stuns": [
|
||||||
{
|
{
|
||||||
"Proto": "udp",
|
"Proto": "udp",
|
||||||
"URI": "stun:{{ subdomain }}.{{ base_domain }}:{{ relay_udp_port }}",
|
"URI": "stun:{{ netbird_domain }}:{{ relay_udp_port }}",
|
||||||
"Username": "",
|
"Username": "",
|
||||||
"Password": null
|
"Password": null
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"Turns": [
|
"Turns": [
|
||||||
{
|
{
|
||||||
"Proto": "udp",
|
"Proto": "udp",
|
||||||
"URI": "turn:{{ subdomain }}.{{ base_domain }}:{{ relay_udp_port }}",
|
"URI": "turn:{{ netbird_domain }}:{{ relay_udp_port }}",
|
||||||
"Username": "netbird",
|
"Username": "netbird",
|
||||||
"Password": "{{ relay_secret }}"
|
"Password": "{{ relay_secret }}"
|
||||||
}
|
}
|
||||||
@@ -22,42 +22,35 @@
|
|||||||
},
|
},
|
||||||
"Relay": {
|
"Relay": {
|
||||||
"Addresses": [
|
"Addresses": [
|
||||||
"rels://{{ subdomain }}.{{ base_domain }}:443"
|
"rels://{{ netbird_domain }}:443"
|
||||||
],
|
],
|
||||||
"CredentialsTTL": "12h",
|
"CredentialsTTL": "24h",
|
||||||
"Secret": "{{ relay_secret }}"
|
"Secret": "{{ relay_secret }}"
|
||||||
},
|
},
|
||||||
"Signal": {
|
"Signal": {
|
||||||
"Proto": "https",
|
"Proto": "{{ netbird_protocol }}",
|
||||||
"URI": "{{ subdomain }}.{{ base_domain }}:443",
|
"URI": "{{ netbird_domain }}:{{ netbird_port }}",
|
||||||
"Username": "",
|
"Username": "",
|
||||||
"Password": null
|
"Password": null
|
||||||
},
|
},
|
||||||
"HttpConfig": {
|
"HttpConfig": {
|
||||||
"AuthIssuer": "https://{{ subdomain }}.{{ base_domain }}",
|
"AuthIssuer": "{{ external_url }}/oauth2",
|
||||||
"AuthAudience": "{{ subdomain }}.{{ base_domain }}",
|
"AuthAudience": "netbird-dashboard",
|
||||||
"OIDCConfigEndpoint": ""
|
"OIDCConfigEndpoint": "{{ external_url }}/oauth2/.well-known/openid-configuration"
|
||||||
},
|
},
|
||||||
"IdpManagerConfig": {
|
"EmbeddedIdP": {
|
||||||
"ManagerType": "none"
|
"Enabled": true,
|
||||||
},
|
"Issuer": "{{ external_url }}/oauth2",
|
||||||
"DeviceAuthorizationFlow": {
|
"LocalAddress": "http://127.0.0.1:80",
|
||||||
"Provider": "none"
|
"DashboardRedirectURIs": [
|
||||||
},
|
"{{ external_url }}/nb-auth",
|
||||||
"PKCEAuthorizationFlow": {
|
"{{ external_url }}/nb-silent-auth"
|
||||||
"ProviderConfig": {
|
|
||||||
"Audience": "{{ subdomain }}.{{ base_domain }}",
|
|
||||||
"ClientID": "",
|
|
||||||
"ClientSecret": "",
|
|
||||||
"Domain": "",
|
|
||||||
"AuthorizationEndpoint": "",
|
|
||||||
"TokenEndpoint": "",
|
|
||||||
"Scope": "openid profile email",
|
|
||||||
"RedirectURLs": [
|
|
||||||
"https://{{ subdomain }}.{{ base_domain }}/auth/callback"
|
|
||||||
],
|
],
|
||||||
"UseIDToken": false
|
"CLIRedirectURIs": [
|
||||||
}
|
"http://localhost:53000/",
|
||||||
|
"http://localhost:54000/"
|
||||||
|
],
|
||||||
|
"SignKeyRefreshEnabled": true
|
||||||
},
|
},
|
||||||
"DataStoreEncryptionKey": "{{ relay_secret }}"
|
"DataStoreEncryptionKey": "{{ datastore_encryption_key }}"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user