First Build alpha 0.1

This commit is contained in:
2026-02-07 12:18:20 +01:00
parent 29e83436b2
commit 42a3cc9d9f
36 changed files with 4982 additions and 51 deletions

175
static/css/styles.css Normal file
View File

@@ -0,0 +1,175 @@
/* NetBird MSP Appliance - Custom Styles */
/* Login */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
}
.login-card {
width: 100%;
max-width: 420px;
border-radius: 12px;
}
/* Stats cards */
.stat-card {
border-radius: 10px;
transition: transform 0.15s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
/* Table */
.table th {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #6c757d;
}
.table td {
vertical-align: middle;
}
/* Log viewer */
.log-viewer {
max-height: 600px;
overflow-y: auto;
}
.log-entry {
padding: 4px 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 0.85rem;
font-family: 'Consolas', 'Monaco', monospace;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: #6c757d;
margin-right: 8px;
}
.log-pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 6px;
max-height: 300px;
overflow-y: auto;
font-size: 0.8rem;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Toast notification */
.toast-notification {
position: fixed;
bottom: 20px;
right: 20px;
background: #198754;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-size: 0.9rem;
z-index: 9999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: toast-in 0.3s ease, toast-out 0.3s ease 2.7s;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-out {
from { opacity: 1; }
to { opacity: 0; }
}
/* Badge improvements */
.badge {
font-weight: 500;
font-size: 0.75rem;
padding: 0.35em 0.65em;
}
/* Page transitions */
.page-content {
animation: fade-in 0.15s ease;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Progress bars in monitoring */
.progress {
border-radius: 6px;
}
.progress-bar {
font-size: 0.75rem;
font-weight: 600;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.stat-card .fs-3 {
font-size: 1.5rem !important;
}
.btn-group-sm .btn {
padding: 0.2rem 0.4rem;
}
}
/* Custom scrollbar */
.log-pre::-webkit-scrollbar,
.log-viewer::-webkit-scrollbar {
width: 6px;
}
.log-pre::-webkit-scrollbar-thumb,
.log-viewer::-webkit-scrollbar-thumb {
background: #555;
border-radius: 3px;
}
/* Navbar brand */
.navbar-brand {
font-weight: 700;
letter-spacing: 0.5px;
}
/* Card improvements */
.card {
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.card-header {
font-weight: 600;
background: rgba(0, 0, 0, 0.02);
}

509
static/index.html Normal file
View File

@@ -0,0 +1,509 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetBird MSP Appliance</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.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">
</head>
<body>
<!-- Login Page -->
<div id="login-page" class="d-none">
<div class="login-container">
<div class="card login-card shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-hdd-network fs-1 text-primary"></i>
<h3 class="mt-2">NetBird MSP Appliance</h3>
<p class="text-muted">Multi-Tenant Management Platform</p>
</div>
<div id="login-error" class="alert alert-danger d-none"></div>
<form id="login-form">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="login-username" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="login-password" required>
</div>
<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>
Sign In
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Main Application -->
<div id="app-page" class="d-none">
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="#"><i class="bi bi-hdd-network me-2"></i>NetBird MSP</a>
<div class="d-flex align-items-center">
<button class="btn btn-success btn-sm me-3" onclick="showNewCustomerModal()">
<i class="bi bi-plus-lg me-1"></i>New Customer
</button>
<div class="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>
</button>
<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('monitoring')"><i class="bi bi-activity me-2"></i>Monitoring</a></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>
</ul>
</div>
</div>
</div>
</nav>
<!-- Page: Dashboard -->
<div id="page-dashboard" class="page-content">
<div class="container-fluid p-4">
<!-- Stats Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card stat-card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Total Customers</div>
<div class="fs-3 fw-bold" id="stat-total">0</div>
</div>
<div class="stat-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-people"></i></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Active</div>
<div class="fs-3 fw-bold text-success" id="stat-active">0</div>
</div>
<div class="stat-icon bg-success bg-opacity-10 text-success"><i class="bi bi-check-circle"></i></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Inactive</div>
<div class="fs-3 fw-bold text-warning" id="stat-inactive">0</div>
</div>
<div class="stat-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-pause-circle"></i></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Errors</div>
<div class="fs-3 fw-bold text-danger" id="stat-error">0</div>
</div>
<div class="stat-icon bg-danger bg-opacity-10 text-danger"><i class="bi bi-exclamation-triangle"></i></div>
</div>
</div>
</div>
</div>
</div>
<!-- Search & Filter -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="row g-2">
<div class="col-md-6">
<input type="text" class="form-control" id="search-input" placeholder="Search by name, subdomain, email...">
</div>
<div class="col-md-3">
<select class="form-select" id="status-filter">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="deploying">Deploying</option>
<option value="error">Error</option>
</select>
</div>
<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>
</div>
</div>
</div>
</div>
<!-- Customers Table -->
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Company</th>
<th>Subdomain</th>
<th>Status</th>
<th>Devices</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="customers-table-body">
<tr><td colspan="8" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<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>
<nav>
<ul class="pagination pagination-sm mb-0" id="pagination-controls"></ul>
</nav>
</div>
</div>
</div>
</div>
<!-- Page: Customer Detail -->
<div id="page-customer-detail" class="page-content d-none">
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<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>
<span class="fs-4 fw-bold" id="detail-customer-name">Customer</span>
<span class="badge ms-2" id="detail-customer-status">active</span>
</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-danger btn-sm" onclick="deleteCurrentCustomer()"><i class="bi bi-trash me-1"></i>Delete</button>
</div>
</div>
<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" data-bs-toggle="tab" href="#tab-deployment">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-health">Health</a></li>
</ul>
<div class="tab-content">
<!-- Tab: Info -->
<div class="tab-pane fade show active" id="tab-info">
<div class="card shadow-sm">
<div class="card-body" id="detail-info-content">Loading...</div>
</div>
</div>
<!-- Tab: Deployment -->
<div class="tab-pane fade" id="tab-deployment">
<div class="card shadow-sm">
<div class="card-body" id="detail-deployment-content">Loading...</div>
</div>
</div>
<!-- Tab: Logs -->
<div class="tab-pane fade" id="tab-logs">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between">
<span>Container Logs</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerLogs()"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
</div>
<div class="card-body">
<div id="detail-logs-content" class="log-viewer">No logs loaded.</div>
</div>
</div>
</div>
<!-- Tab: Health -->
<div class="tab-pane fade" id="tab-health">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between">
<span>Health Check</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerHealth()"><i class="bi bi-arrow-clockwise"></i> Check</button>
</div>
<div class="card-body" id="detail-health-content">Click "Check" to run a health check.</div>
</div>
</div>
</div>
</div>
</div>
<!-- Page: Settings -->
<div id="page-settings" class="page-content d-none">
<div class="container-fluid p-4">
<h4 class="mb-4"><i class="bi bi-gear me-2"></i>System Settings</h4>
<div id="settings-alert" class="d-none"></div>
<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" 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-images">Docker Images</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security">Security</a></li>
</ul>
<div class="tab-content">
<!-- System Config -->
<div class="tab-pane fade show active" id="settings-system">
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-system-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Base Domain</label>
<input type="text" class="form-control" id="cfg-base-domain" placeholder="yourdomain.com">
<div class="form-text">Customers get subdomains: kunde.yourdomain.com</div>
</div>
<div class="col-md-6">
<label class="form-label">Admin Email</label>
<input type="email" class="form-control" id="cfg-admin-email" placeholder="admin@yourdomain.com">
</div>
<div class="col-md-6">
<label class="form-label">Data Directory</label>
<input type="text" class="form-control" id="cfg-data-dir" placeholder="/opt/netbird-instances">
</div>
<div class="col-md-6">
<label class="form-label">Docker Network</label>
<input type="text" class="form-control" id="cfg-docker-network" placeholder="npm-network">
</div>
<div class="col-md-6">
<label class="form-label">Relay Base Port</label>
<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>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save System Settings</button>
</div>
</form>
</div>
</div>
</div>
<!-- NPM Integration -->
<div class="tab-pane fade" id="settings-npm">
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-npm-form">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">NPM API URL</label>
<input type="url" class="form-control" id="cfg-npm-api-url" placeholder="http://nginx-proxy-manager:81/api">
</div>
<div class="col-md-8">
<label class="form-label">NPM API Token</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-npm-api-token" placeholder="Leave empty to keep current token">
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-npm-api-token')"><i class="bi bi-eye"></i></button>
</div>
<div class="form-text" id="npm-token-status"></div>
</div>
</div>
<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="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>
<i class="bi bi-plug me-1"></i>Test Connection
</button>
</div>
</form>
<div id="npm-test-result" class="mt-3 d-none"></div>
</div>
</div>
</div>
<!-- Docker Images -->
<div class="tab-pane fade" id="settings-images">
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-images-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Management Image</label>
<input type="text" class="form-control" id="cfg-mgmt-image" placeholder="netbirdio/management:latest">
</div>
<div class="col-md-6">
<label class="form-label">Signal Image</label>
<input type="text" class="form-control" id="cfg-signal-image" placeholder="netbirdio/signal:latest">
</div>
<div class="col-md-6">
<label class="form-label">Relay Image</label>
<input type="text" class="form-control" id="cfg-relay-image" placeholder="netbirdio/relay:latest">
</div>
<div class="col-md-6">
<label class="form-label">Dashboard Image</label>
<input type="text" class="form-control" id="cfg-dashboard-image" placeholder="netbirdio/dashboard:latest">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save Image Settings</button>
</div>
</form>
</div>
</div>
</div>
<!-- Security -->
<div class="tab-pane fade" id="settings-security">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3">Change Admin Password</h5>
<form id="change-password-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Current Password</label>
<input type="password" class="form-control" id="pw-current" required>
</div>
<div class="col-md-6"></div>
<div class="col-md-6">
<label class="form-label">New Password (min 12 chars)</label>
<input type="password" class="form-control" id="pw-new" required minlength="12">
</div>
<div class="col-md-6">
<label class="form-label">Confirm New Password</label>
<input type="password" class="form-control" id="pw-confirm" required minlength="12">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-warning"><i class="bi bi-shield-lock me-1"></i>Change Password</button>
</div>
</form>
<div id="password-result" class="mt-3 d-none"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Page: Monitoring -->
<div id="page-monitoring" class="page-content d-none">
<div class="container-fluid p-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>
<button class="btn btn-outline-secondary btn-sm" onclick="loadMonitoring()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
</div>
<!-- Host Resources -->
<div class="card shadow-sm mb-4">
<div class="card-header">Host Resources</div>
<div class="card-body" id="monitoring-resources">Loading...</div>
</div>
<!-- Customer Statuses -->
<div class="card shadow-sm">
<div class="card-header">All Customer Deployments</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Subdomain</th>
<th>Status</th>
<th>Deployment</th>
<th>Relay Port</th>
<th>Containers</th>
</tr>
</thead>
<tbody id="monitoring-customers-body">
<tr><td colspan="7" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: New/Edit Customer -->
<div class="modal fade" id="customer-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="customer-modal-title">New Customer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="customer-modal-error" class="alert alert-danger d-none"></div>
<form id="customer-form">
<input type="hidden" id="customer-edit-id">
<div class="mb-3">
<label class="form-label">Name *</label>
<input type="text" class="form-control" id="cust-name" required>
</div>
<div class="mb-3">
<label class="form-label">Company</label>
<input type="text" class="form-control" id="cust-company">
</div>
<div class="mb-3">
<label class="form-label">Subdomain *</label>
<div class="input-group">
<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>
</div>
<div class="form-text">Lowercase, alphanumeric + hyphens</div>
</div>
<div class="mb-3">
<label class="form-label">Email *</label>
<input type="email" class="form-control" id="cust-email" required>
</div>
<div class="mb-3">
<label class="form-label">Max Devices</label>
<input type="number" class="form-control" id="cust-max-devices" value="20" min="1">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" id="cust-notes" rows="2"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<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>
Save & Deploy
</button>
</div>
</div>
</div>
</div>
<!-- Modal: Delete Confirmation -->
<div class="modal fade" id="delete-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">Confirm Deletion</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete customer <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>
<input type="hidden" id="delete-customer-id">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">
<span class="spinner-border spinner-border-sm d-none me-1" id="delete-spinner"></span>
Delete
</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="/static/js/app.js"></script>
</body>
</html>

694
static/js/app.js Normal file
View File

@@ -0,0 +1,694 @@
/**
* NetBird MSP Appliance - Frontend Application
* Vanilla JavaScript with Bootstrap 5
*/
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let authToken = localStorage.getItem('authToken') || null;
let currentUser = null;
let currentPage = 'dashboard';
let currentCustomerId = null;
let currentCustomerData = null;
let customersPage = 1;
// ---------------------------------------------------------------------------
// API helper
// ---------------------------------------------------------------------------
async function api(method, path, body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (authToken) {
opts.headers['Authorization'] = `Bearer ${authToken}`;
}
if (body) {
opts.body = JSON.stringify(body);
}
const resp = await fetch(`/api${path}`, opts);
if (resp.status === 401) {
logout();
throw new Error('Session expired.');
}
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.detail || data.message || 'Request failed.');
}
return data;
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
function initApp() {
if (authToken) {
api('GET', '/auth/me')
.then(user => {
currentUser = user;
document.getElementById('nav-username').textContent = user.username;
showAppPage();
loadDashboard();
})
.catch(() => {
authToken = null;
localStorage.removeItem('authToken');
showLoginPage();
});
} else {
showLoginPage();
}
}
function showLoginPage() {
document.getElementById('login-page').classList.remove('d-none');
document.getElementById('app-page').classList.add('d-none');
}
function showAppPage() {
document.getElementById('login-page').classList.add('d-none');
document.getElementById('app-page').classList.remove('d-none');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
const spinner = document.getElementById('login-spinner');
errorEl.classList.add('d-none');
spinner.classList.remove('d-none');
try {
const data = await api('POST', '/auth/login', {
username: document.getElementById('login-username').value,
password: document.getElementById('login-password').value,
});
authToken = data.access_token;
localStorage.setItem('authToken', authToken);
currentUser = data.user;
document.getElementById('nav-username').textContent = currentUser.username;
showAppPage();
loadDashboard();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
}
});
function logout() {
api('POST', '/auth/logout').catch(() => {});
authToken = null;
currentUser = null;
localStorage.removeItem('authToken');
showLoginPage();
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
function showPage(page) {
document.querySelectorAll('.page-content').forEach(el => el.classList.add('d-none'));
document.getElementById(`page-${page}`).classList.remove('d-none');
currentPage = page;
if (page === 'dashboard') loadDashboard();
else if (page === 'settings') loadSettings();
else if (page === 'monitoring') loadMonitoring();
}
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
async function loadDashboard() {
await Promise.all([loadStats(), loadCustomers()]);
}
async function loadStats() {
try {
const data = await api('GET', '/monitoring/status');
document.getElementById('stat-total').textContent = data.total_customers;
document.getElementById('stat-active').textContent = data.active;
document.getElementById('stat-inactive').textContent = data.inactive;
document.getElementById('stat-error').textContent = data.error;
} catch (err) {
console.error('Failed to load stats:', err);
}
}
async function loadCustomers() {
const search = document.getElementById('search-input').value;
const status = document.getElementById('status-filter').value;
let url = `/customers?page=${customersPage}&per_page=25`;
if (search) url += `&search=${encodeURIComponent(search)}`;
if (status) url += `&status=${encodeURIComponent(status)}`;
try {
const data = await api('GET', url);
renderCustomersTable(data);
} catch (err) {
console.error('Failed to load customers:', err);
}
}
function renderCustomersTable(data) {
const tbody = document.getElementById('customers-table-body');
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>';
document.getElementById('pagination-info').textContent = 'Showing 0 of 0';
document.getElementById('pagination-controls').innerHTML = '';
return;
}
tbody.innerHTML = data.items.map(c => `
<tr>
<td>${c.id}</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>${statusBadge(c.status)}</td>
<td>${c.max_devices}</td>
<td>${formatDate(c.created_at)}</td>
<td>
<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>
${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-success" title="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-danger" title="Delete" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`).join('');
// Pagination
const start = (data.page - 1) * data.per_page + 1;
const end = Math.min(data.page * data.per_page, data.total);
document.getElementById('pagination-info').textContent = `Showing ${start}-${end} of ${data.total}`;
let paginationHtml = '';
for (let i = 1; i <= data.pages; i++) {
paginationHtml += `<li class="page-item ${i === data.page ? 'active' : ''}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
}
document.getElementById('pagination-controls').innerHTML = paginationHtml;
}
function goToPage(page) {
customersPage = page;
loadCustomers();
}
// Search & filter listeners
document.getElementById('search-input').addEventListener('input', debounce(() => { customersPage = 1; loadCustomers(); }, 300));
document.getElementById('status-filter').addEventListener('change', () => { customersPage = 1; loadCustomers(); });
// ---------------------------------------------------------------------------
// Customer CRUD
// ---------------------------------------------------------------------------
function showNewCustomerModal() {
document.getElementById('customer-modal-title').textContent = 'New Customer';
document.getElementById('customer-edit-id').value = '';
document.getElementById('customer-form').reset();
document.getElementById('cust-max-devices').value = '20';
document.getElementById('customer-modal-error').classList.add('d-none');
document.getElementById('customer-save-btn').textContent = 'Save & Deploy';
// Update subdomain suffix
api('GET', '/settings/system').then(cfg => {
document.getElementById('cust-subdomain-suffix').textContent = `.${cfg.base_domain || 'domain.com'}`;
}).catch(() => {});
const modal = new bootstrap.Modal(document.getElementById('customer-modal'));
// Enable subdomain field for new customers
document.getElementById('cust-subdomain').disabled = false;
modal.show();
}
function editCurrentCustomer() {
if (!currentCustomerData) return;
const c = currentCustomerData;
document.getElementById('customer-modal-title').textContent = 'Edit Customer';
document.getElementById('customer-edit-id').value = c.id;
document.getElementById('cust-name').value = c.name;
document.getElementById('cust-company').value = c.company || '';
document.getElementById('cust-subdomain').value = c.subdomain;
document.getElementById('cust-subdomain').disabled = true; // Can't change subdomain
document.getElementById('cust-email').value = c.email;
document.getElementById('cust-max-devices').value = c.max_devices;
document.getElementById('cust-notes').value = c.notes || '';
document.getElementById('customer-modal-error').classList.add('d-none');
document.getElementById('customer-save-btn').textContent = 'Save Changes';
const modal = new bootstrap.Modal(document.getElementById('customer-modal'));
modal.show();
}
async function saveCustomer() {
const errorEl = document.getElementById('customer-modal-error');
const spinner = document.getElementById('customer-save-spinner');
errorEl.classList.add('d-none');
spinner.classList.remove('d-none');
const editId = document.getElementById('customer-edit-id').value;
const payload = {
name: document.getElementById('cust-name').value,
company: document.getElementById('cust-company').value || null,
email: document.getElementById('cust-email').value,
max_devices: parseInt(document.getElementById('cust-max-devices').value) || 20,
notes: document.getElementById('cust-notes').value || null,
};
try {
if (editId) {
await api('PUT', `/customers/${editId}`, payload);
} else {
payload.subdomain = document.getElementById('cust-subdomain').value.toLowerCase();
await api('POST', '/customers', payload);
}
bootstrap.Modal.getInstance(document.getElementById('customer-modal')).hide();
loadDashboard();
if (editId && currentCustomerId == editId) {
viewCustomer(editId);
}
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
}
}
function showDeleteModal(id, name) {
document.getElementById('delete-customer-id').value = id;
document.getElementById('delete-customer-name').textContent = name;
new bootstrap.Modal(document.getElementById('delete-modal')).show();
}
function deleteCurrentCustomer() {
if (!currentCustomerData) return;
showDeleteModal(currentCustomerData.id, currentCustomerData.name);
}
async function confirmDeleteCustomer() {
const id = document.getElementById('delete-customer-id').value;
const spinner = document.getElementById('delete-spinner');
spinner.classList.remove('d-none');
try {
await api('DELETE', `/customers/${id}`);
bootstrap.Modal.getInstance(document.getElementById('delete-modal')).hide();
showPage('dashboard');
} catch (err) {
alert('Delete failed: ' + err.message);
} finally {
spinner.classList.add('d-none');
}
}
// ---------------------------------------------------------------------------
// Customer Actions (start/stop/restart)
// ---------------------------------------------------------------------------
async function customerAction(id, action) {
try {
await api('POST', `/customers/${id}/${action}`);
if (currentPage === 'dashboard') loadCustomers();
if (currentCustomerId == id) viewCustomer(id);
} catch (err) {
alert(`${action} failed: ${err.message}`);
}
}
// ---------------------------------------------------------------------------
// Customer Detail
// ---------------------------------------------------------------------------
async function viewCustomer(id) {
currentCustomerId = id;
showPage('customer-detail');
try {
const data = await api('GET', `/customers/${id}`);
currentCustomerData = data;
document.getElementById('detail-customer-name').textContent = data.name;
const badge = document.getElementById('detail-customer-status');
badge.innerHTML = statusBadge(data.status);
// Info tab
document.getElementById('detail-info-content').innerHTML = `
<div class="row g-3">
<div class="col-md-6"><strong>Name:</strong> ${esc(data.name)}</div>
<div class="col-md-6"><strong>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>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>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>Updated:</strong> ${formatDate(data.updated_at)}</div>
${data.notes ? `<div class="col-12"><strong>Notes:</strong> ${esc(data.notes)}</div>` : ''}
</div>
`;
// Deployment tab
if (data.deployment) {
const d = data.deployment;
document.getElementById('detail-deployment-content').innerHTML = `
<div class="row g-3">
<div class="col-md-6"><strong>Status:</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>Container Prefix:</strong> <code>${esc(d.container_prefix)}</code></div>
<div class="col-md-6"><strong>Deployed:</strong> ${formatDate(d.deployed_at)}</div>
<div class="col-12">
<strong>Setup URL:</strong>
<div class="input-group mt-1">
<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>
</div>
</div>
</div>
<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-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>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-outline-primary btn-sm" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Re-Deploy</button>
</div>
`;
} else {
document.getElementById('detail-deployment-content').innerHTML = `
<p class="text-muted">No deployment found.</p>
<button class="btn btn-primary" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Deploy Now</button>
`;
}
// Logs tab (preview from deployment_logs table)
if (data.logs && data.logs.length > 0) {
document.getElementById('detail-logs-content').innerHTML = data.logs.map(l =>
`<div class="log-entry log-${l.status}"><span class="log-time">${formatDate(l.created_at)}</span> <span class="badge bg-${l.status === 'success' ? 'success' : l.status === 'error' ? 'danger' : 'info'}">${l.status}</span> <strong>${esc(l.action)}</strong>: ${esc(l.message)}</div>`
).join('');
}
} catch (err) {
document.getElementById('detail-info-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
async function loadCustomerLogs() {
if (!currentCustomerId) return;
try {
const data = await api('GET', `/customers/${currentCustomerId}/logs`);
const content = document.getElementById('detail-logs-content');
if (!data.logs || Object.keys(data.logs).length === 0) {
content.innerHTML = '<p class="text-muted">No container logs available.</p>';
return;
}
let html = '';
for (const [name, logText] of Object.entries(data.logs)) {
html += `<h6 class="mt-3"><i class="bi bi-box me-1"></i>${esc(name)}</h6><pre class="log-pre">${esc(logText)}</pre>`;
}
content.innerHTML = html;
} catch (err) {
document.getElementById('detail-logs-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
async function loadCustomerHealth() {
if (!currentCustomerId) return;
try {
const data = await api('GET', `/customers/${currentCustomerId}/health`);
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>`;
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>';
data.containers.forEach(c => {
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>`;
});
html += '</tbody></table>';
}
html += `<div class="text-muted small">Last check: ${formatDate(data.last_check)}</div>`;
content.innerHTML = html;
} catch (err) {
document.getElementById('detail-health-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
function copySetupUrl() {
const input = document.getElementById('setup-url-input');
navigator.clipboard.writeText(input.value).then(() => {
showToast('Setup URL copied to clipboard.');
});
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
async function loadSettings() {
try {
const cfg = await api('GET', '/settings/system');
document.getElementById('cfg-base-domain').value = cfg.base_domain || '';
document.getElementById('cfg-admin-email').value = cfg.admin_email || '';
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
document.getElementById('cfg-docker-network').value = cfg.docker_network || '';
document.getElementById('cfg-relay-base-port').value = cfg.relay_base_port || 3478;
document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || '';
document.getElementById('npm-token-status').textContent = cfg.npm_api_token_set ? 'Token is set (leave empty to keep current)' : 'No token configured';
document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || '';
document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || '';
document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || '';
document.getElementById('cfg-dashboard-image').value = cfg.netbird_dashboard_image || '';
} catch (err) {
showSettingsAlert('danger', 'Failed to load settings: ' + err.message);
}
}
// System settings form
document.getElementById('settings-system-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PUT', '/settings/system', {
base_domain: document.getElementById('cfg-base-domain').value,
admin_email: document.getElementById('cfg-admin-email').value,
data_dir: document.getElementById('cfg-data-dir').value,
docker_network: document.getElementById('cfg-docker-network').value,
relay_base_port: parseInt(document.getElementById('cfg-relay-base-port').value),
});
showSettingsAlert('success', 'System settings saved.');
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
}
});
// NPM settings form
document.getElementById('settings-npm-form').addEventListener('submit', async (e) => {
e.preventDefault();
const payload = { npm_api_url: document.getElementById('cfg-npm-api-url').value };
const token = document.getElementById('cfg-npm-api-token').value;
if (token) payload.npm_api_token = token;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', 'NPM settings saved.');
document.getElementById('cfg-npm-api-token').value = '';
loadSettings();
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
}
});
// Image settings form
document.getElementById('settings-images-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('PUT', '/settings/system', {
netbird_management_image: document.getElementById('cfg-mgmt-image').value,
netbird_signal_image: document.getElementById('cfg-signal-image').value,
netbird_relay_image: document.getElementById('cfg-relay-image').value,
netbird_dashboard_image: document.getElementById('cfg-dashboard-image').value,
});
showSettingsAlert('success', 'Image settings saved.');
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
}
});
// Test NPM connection
async function testNpmConnection() {
const spinner = document.getElementById('npm-test-spinner');
const resultEl = document.getElementById('npm-test-result');
spinner.classList.remove('d-none');
resultEl.classList.add('d-none');
try {
const data = await api('GET', '/settings/test-npm');
resultEl.className = `mt-3 alert alert-${data.ok ? 'success' : 'danger'}`;
resultEl.textContent = data.message;
resultEl.classList.remove('d-none');
} catch (err) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = err.message;
resultEl.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
}
}
// Change password form
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const resultEl = document.getElementById('password-result');
const newPw = document.getElementById('pw-new').value;
const confirmPw = document.getElementById('pw-confirm').value;
if (newPw !== confirmPw) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = 'Passwords do not match.';
resultEl.classList.remove('d-none');
return;
}
try {
await api('POST', '/auth/change-password', {
current_password: document.getElementById('pw-current').value,
new_password: newPw,
});
resultEl.className = 'mt-3 alert alert-success';
resultEl.textContent = 'Password changed successfully.';
resultEl.classList.remove('d-none');
document.getElementById('change-password-form').reset();
} catch (err) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = err.message;
resultEl.classList.remove('d-none');
}
});
function showSettingsAlert(type, msg) {
const el = document.getElementById('settings-alert');
el.className = `alert alert-${type} alert-dismissible fade show`;
el.innerHTML = `${msg}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
el.classList.remove('d-none');
setTimeout(() => el.classList.add('d-none'), 5000);
}
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
input.type = input.type === 'password' ? 'text' : 'password';
}
// ---------------------------------------------------------------------------
// Monitoring
// ---------------------------------------------------------------------------
async function loadMonitoring() {
await Promise.all([loadResources(), loadAllCustomerStatuses()]);
}
async function loadResources() {
try {
const data = await api('GET', '/monitoring/resources');
document.getElementById('monitoring-resources').innerHTML = `
<div class="row g-3">
<div class="col-md-3">
<div class="text-muted small">Hostname</div>
<div class="fw-bold">${esc(data.hostname)}</div>
<div class="text-muted small">${esc(data.os)}</div>
</div>
<div class="col-md-3">
<div class="text-muted small">CPU (${data.cpu.count} cores)</div>
<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'}"
style="width: ${data.cpu.percent}%">${data.cpu.percent}%</div>
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Memory (${data.memory.used_gb}/${data.memory.total_gb} GB)</div>
<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'}"
style="width: ${data.memory.percent}%">${data.memory.percent}%</div>
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Disk (${data.disk.used_gb}/${data.disk.total_gb} GB)</div>
<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'}"
style="width: ${data.disk.percent}%">${data.disk.percent}%</div>
</div>
</div>
</div>
`;
} catch (err) {
document.getElementById('monitoring-resources').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
}
}
async function loadAllCustomerStatuses() {
try {
const data = await api('GET', '/monitoring/customers');
const tbody = document.getElementById('monitoring-customers-body');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No customers.</td></tr>';
return;
}
tbody.innerHTML = data.map(c => {
const containerInfo = c.containers.map(ct => `${ct.name}: ${ct.status}`).join(', ') || '-';
return `<tr>
<td>${c.id}</td>
<td>${esc(c.name)}</td>
<td><code>${esc(c.subdomain)}</code></td>
<td>${statusBadge(c.status)}</td>
<td>${c.deployment_status ? statusBadge(c.deployment_status) : '-'}</td>
<td>${c.relay_udp_port || '-'}</td>
<td class="small">${esc(containerInfo)}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('monitoring-customers-body').innerHTML = `<tr><td colspan="7" class="text-danger">${err.message}</td></tr>`;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function statusBadge(status) {
const map = {
active: 'success', running: 'success',
inactive: 'secondary', stopped: 'secondary',
deploying: 'info', pending: 'info',
error: 'danger', failed: 'danger',
};
const color = map[status] || 'secondary';
return `<span class="badge bg-${color}">${status}</span>`;
}
function formatDate(isoStr) {
if (!isoStr) return '-';
const d = new Date(isoStr);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function esc(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function showToast(message) {
// Simple inline notification
const el = document.createElement('div');
el.className = 'toast-notification';
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', initApp);