First Build alpha 0.1
This commit is contained in:
175
static/css/styles.css
Normal file
175
static/css/styles.css
Normal 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
509
static/index.html
Normal 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
694
static/js/app.js
Normal 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);
|
||||
Reference in New Issue
Block a user