/**
* 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);
}
let resp;
try {
resp = await fetch(`/api${path}`, opts);
} catch (networkErr) {
console.error(`API network error: ${method} ${path}`, networkErr);
throw new Error('Network error — server not reachable.');
}
if (resp.status === 401) {
logout();
throw new Error('Session expired.');
}
let data;
try {
data = await resp.json();
} catch (jsonErr) {
console.error(`API JSON parse error: ${method} ${path} (status ${resp.status})`, jsonErr);
throw new Error(`Server error (HTTP ${resp.status}).`);
}
if (!resp.ok) {
const msg = data.detail || data.message || 'Request failed.';
console.error(`API error: ${method} ${path} (status ${resp.status})`, msg);
throw new Error(msg);
}
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() {
// Use fetch directly (not api()) to avoid 401 → logout → 401 infinite loop
if (authToken) {
fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
}).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 = '
| No customers found. Click "New Customer" to create one. |
';
document.getElementById('pagination-info').textContent = 'Showing 0 of 0';
document.getElementById('pagination-controls').innerHTML = '';
return;
}
tbody.innerHTML = data.items.map(c => `
| ${c.id} |
${esc(c.name)} |
${esc(c.company || '-')} |
${esc(c.subdomain)} |
${statusBadge(c.status)} |
${c.max_devices} |
${formatDate(c.created_at)} |
${c.deployment && c.deployment.deployment_status === 'running'
? ``
: ``
}
|
`).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 += `${i}`;
}
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').innerHTML = ' Save & Deploy';
// Update subdomain suffix
api('GET', '/settings/system').then(cfg => {
document.getElementById('cust-subdomain-suffix').textContent = `.${cfg.base_domain || 'domain.com'}`;
}).catch(() => {});
const modalEl = document.getElementById('customer-modal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
// 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').innerHTML = ' Save Changes';
const modalEl = document.getElementById('customer-modal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
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);
}
// Close modal safely
const modalEl = document.getElementById('customer-modal');
const modalInstance = bootstrap.Modal.getInstance(modalEl);
if (modalInstance) {
modalInstance.hide();
} else {
modalEl.classList.remove('show');
document.body.classList.remove('modal-open');
document.querySelector('.modal-backdrop')?.remove();
}
loadDashboard();
if (editId && currentCustomerId == editId) {
viewCustomer(editId);
}
} catch (err) {
console.error('saveCustomer error:', err);
errorEl.textContent = err.message || 'An unknown error occurred.';
errorEl.classList.remove('d-none');
errorEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} 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 = `
Name: ${esc(data.name)}
Company: ${esc(data.company || '-')}
Subdomain: ${esc(data.subdomain)}
Email: ${esc(data.email)}
Max Devices: ${data.max_devices}
Status: ${statusBadge(data.status)}
Created: ${formatDate(data.created_at)}
Updated: ${formatDate(data.updated_at)}
${data.notes ? `
Notes: ${esc(data.notes)}
` : ''}
`;
// Deployment tab
if (data.deployment) {
const d = data.deployment;
document.getElementById('detail-deployment-content').innerHTML = `
Status: ${statusBadge(d.deployment_status)}
Relay UDP Port: ${d.relay_udp_port}
Container Prefix: ${esc(d.container_prefix)}
Deployed: ${formatDate(d.deployed_at)}
`;
} else {
document.getElementById('detail-deployment-content').innerHTML = `
No deployment found.
`;
}
// Logs tab (preview from deployment_logs table)
if (data.logs && data.logs.length > 0) {
document.getElementById('detail-logs-content').innerHTML = data.logs.map(l =>
`${formatDate(l.created_at)} ${l.status} ${esc(l.action)}: ${esc(l.message)}
`
).join('');
}
} catch (err) {
document.getElementById('detail-info-content').innerHTML = `${err.message}
`;
}
}
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 = 'No container logs available.
';
return;
}
let html = '';
for (const [name, logText] of Object.entries(data.logs)) {
html += `${esc(name)}
${esc(logText)}`;
}
content.innerHTML = html;
} catch (err) {
document.getElementById('detail-logs-content').innerHTML = `${err.message}
`;
}
}
async function loadCustomerHealth() {
if (!currentCustomerId) return;
try {
const data = await api('GET', `/customers/${currentCustomerId}/health`);
const content = document.getElementById('detail-health-content');
let html = `Overall: ${data.healthy ? 'Healthy' : 'Unhealthy'}
`;
if (data.containers && data.containers.length > 0) {
html += '| Container | Status | Health | Image |
';
data.containers.forEach(c => {
const statusClass = c.status === 'running' ? 'text-success' : 'text-danger';
html += `| ${esc(c.name)} | ${c.status} | ${c.health} | ${esc(c.image)} |
`;
});
html += '
';
}
html += `Last check: ${formatDate(data.last_check)}
`;
content.innerHTML = html;
} catch (err) {
document.getElementById('detail-health-content').innerHTML = `${err.message}
`;
}
}
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-credentials-status').textContent = cfg.npm_credentials_set ? 'Credentials are set (leave empty to keep current)' : 'No NPM credentials 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 email = document.getElementById('cfg-npm-api-email').value;
const password = document.getElementById('cfg-npm-api-password').value;
if (email) payload.npm_api_email = email;
if (password) payload.npm_api_password = password;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', 'NPM settings saved.');
document.getElementById('cfg-npm-api-email').value = '';
document.getElementById('cfg-npm-api-password').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}`;
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 = `
Hostname
${esc(data.hostname)}
${esc(data.os)}
CPU (${data.cpu.count} cores)
Memory (${data.memory.used_gb}/${data.memory.total_gb} GB)
Disk (${data.disk.used_gb}/${data.disk.total_gb} GB)
`;
} catch (err) {
document.getElementById('monitoring-resources').innerHTML = `${err.message}
`;
}
}
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 = '| No customers. |
';
return;
}
tbody.innerHTML = data.map(c => {
const containerInfo = c.containers.map(ct => `${ct.name}: ${ct.status}`).join(', ') || '-';
return `
| ${c.id} |
${esc(c.name)} |
${esc(c.subdomain)} |
${statusBadge(c.status)} |
${c.deployment_status ? statusBadge(c.deployment_status) : '-'} |
${c.relay_udp_port || '-'} |
${esc(containerInfo)} |
`;
}).join('');
} catch (err) {
document.getElementById('monitoring-customers-body').innerHTML = `| ${err.message} |
`;
}
}
// ---------------------------------------------------------------------------
// 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 `${status}`;
}
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);