/** * 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)}
    Setup URL:
    `; } 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 += ''; data.containers.forEach(c => { const statusClass = c.status === 'running' ? 'text-success' : 'text-danger'; html += ``; }); html += '
    ContainerStatusHealthImage
    ${esc(c.name)}${c.status}${c.health}${esc(c.image)}
    '; } 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)
    ${data.cpu.percent}%
    Memory (${data.memory.used_gb}/${data.memory.total_gb} GB)
    ${data.memory.percent}%
    Disk (${data.disk.used_gb}/${data.disk.total_gb} GB)
    ${data.disk.percent}%
    `; } 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);