2025-09-18 02:56:59 +03:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Xray Admin Panel< / title >
< style >
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
min-height: 100vh;
}
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 260px;
background: white;
border-right: 1px solid #d2d2d7;
padding: 20px 0;
}
.sidebar-header {
padding: 0 20px 20px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 20px;
}
.sidebar-header h1 {
font-size: 20px;
font-weight: 600;
color: #1d1d1f;
}
.nav-menu {
list-style: none;
}
.nav-item {
margin-bottom: 2px;
}
.nav-link {
display: block;
padding: 12px 20px;
color: #424245;
text-decoration: none;
font-weight: 400;
transition: all 0.2s;
}
.nav-link:hover,
.nav-link.active {
background: #f0f0f0;
color: #0071e3;
}
.main-content {
flex: 1;
padding: 30px;
overflow-y: auto;
}
.page-header {
margin-bottom: 30px;
2025-09-24 00:30:03 +01:00
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.page-header-content {
flex: 1;
2025-09-18 02:56:59 +03:00
}
.page-title {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
}
.page-subtitle {
color: #6e6e73;
font-size: 16px;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.card-header {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.card-subtitle {
color: #6e6e73;
font-size: 14px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn-primary {
background: #0071e3;
color: white;
}
.btn-primary:hover {
background: #0077ed;
}
.btn-secondary {
background: #f5f5f7;
color: #1d1d1f;
border: 1px solid #d2d2d7;
}
.btn-secondary:hover {
background: #e8e8ed;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #0071e3;
margin-bottom: 4px;
}
.stat-label {
color: #6e6e73;
font-size: 14px;
font-weight: 500;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
.table th {
font-weight: 600;
color: #6e6e73;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table tr:hover {
background: #f8f9fa;
}
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-online {
background: #d4edda;
color: #155724;
}
.status-offline {
background: #f8d7da;
color: #721c24;
}
.status-unknown {
background: #e2e3e5;
color: #6c757d;
}
.status-success {
color: #28a745;
font-weight: 500;
}
.status-error {
color: #dc3545;
font-weight: 500;
}
.status-running {
color: #007bff;
font-weight: 500;
}
.status-idle {
color: #6c757d;
font-weight: 500;
}
.task-stats {
font-size: 0.875rem;
}
.success-rate {
margin-top: 2px;
font-size: 0.75rem;
font-weight: 500;
}
.success-rate-good {
color: #28a745;
}
.success-rate-ok {
color: #ffc107;
}
.success-rate-bad {
color: #dc3545;
}
.actions {
display: flex;
gap: 8px;
}
.page-section {
display: none;
}
.page-section.active {
display: block;
}
2025-09-23 14:17:32 +01:00
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 24px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h2 {
margin: 0;
}
.modal-body {
margin-bottom: 20px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.server-section {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.server-section h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
}
.inbound-item {
margin: 5px 0;
}
.inbound-item label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.inbound-item input[type="checkbox"] {
cursor: pointer;
}
.required {
color: #dc3545;
}
2025-09-18 02:56:59 +03:00
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 14px;
color: #1d1d1f;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d2d2d7;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #0071e3;
}
.form-select {
width: 100%;
padding: 10px 12px;
border: 1px solid #d2d2d7;
border-radius: 8px;
font-size: 14px;
background: white;
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
display: none;
}
.alert.show {
display: block;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #6e6e73;
}
.empty-state h3 {
margin-bottom: 8px;
color: #1d1d1f;
}
.loading {
text-align: center;
padding: 40px;
color: #6e6e73;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
width: 90%;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.modal-body {
padding: 20px;
}
.server-section {
margin-bottom: 30px;
padding: 15px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.server-section h3 {
margin: 0 0 15px 0;
font-size: 16px;
font-weight: 600;
color: #1d1d1f;
}
.inbound-item {
margin: 10px 0;
}
.inbound-item label {
display: flex;
align-items: center;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.inbound-item label:hover {
background: #f8f9fa;
}
.inbound-item input[type="checkbox"] {
margin-right: 10px;
}
.user-details {
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.user-details label {
display: block;
margin-bottom: 15px;
font-weight: 500;
}
.user-details input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 4px;
margin-top: 5px;
}
.input-group {
display: flex;
margin-top: 5px;
}
.input-group input {
margin-top: 0;
border-radius: 4px 0 0 4px;
border-right: none;
}
.refresh-btn {
background: #f8f9fa;
border: 1px solid #d2d2d7;
border-radius: 0 4px 4px 0;
padding: 8px 12px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
.refresh-btn:hover {
background: #e9ecef;
}
.modal-actions {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
2025-09-24 00:30:03 +01:00
/* Task Summary Cards */
.task-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.summary-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
gap: 15px;
}
.summary-icon {
font-size: 32px;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f7;
border-radius: 12px;
}
.summary-content h3 {
font-size: 24px;
font-weight: 600;
margin: 0 0 4px 0;
color: #1d1d1f;
}
.summary-content p {
margin: 0;
color: #6e6e73;
font-size: 14px;
}
/* Task Status Badges */
.task-status {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.task-status.running {
background: #e3f2fd;
color: #1976d2;
}
.task-status.success {
background: #e8f5e8;
color: #2e7d32;
}
.task-status.error {
background: #ffebee;
color: #c62828;
}
.task-status.idle {
background: #f5f5f5;
color: #616161;
}
/* Task Actions */
.task-actions {
display: flex;
gap: 8px;
}
.task-actions .btn {
padding: 6px 12px;
font-size: 12px;
}
/* Task List Items */
.task-item {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border: 1px solid #f0f0f0;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.task-info h3 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #1d1d1f;
}
.task-description {
color: #6e6e73;
font-size: 14px;
margin: 0 0 8px 0;
line-height: 1.4;
}
.task-schedule {
color: #8e8e93;
font-size: 13px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
margin: 0;
}
.task-status-container {
display: flex;
align-items: center;
gap: 10px;
}
.task-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.stat {
text-align: center;
}
.stat .stat-value {
font-size: 20px;
font-weight: 600;
color: #1d1d1f;
margin: 0 0 2px 0;
}
.stat .stat-label {
font-size: 12px;
color: #8e8e93;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0;
}
2025-09-18 02:56:59 +03:00
< / style >
< / head >
< body >
< div class = "layout" >
< aside class = "sidebar" >
< div class = "sidebar-header" >
< h1 > Xray Admin< / h1 >
< / div >
< nav >
< ul class = "nav-menu" >
< li class = "nav-item" >
< a href = "#dashboard" class = "nav-link active" onclick = "showPage('dashboard')" > Dashboard< / a >
< / li >
< li class = "nav-item" >
< a href = "#servers" class = "nav-link" onclick = "showPage('servers')" > Servers< / a >
< / li >
< li class = "nav-item" >
< a href = "#templates" class = "nav-link" onclick = "showPage('templates')" > Templates< / a >
< / li >
< li class = "nav-item" >
< a href = "#certificates" class = "nav-link" onclick = "showPage('certificates')" > Certificates< / a >
< / li >
2025-09-24 00:30:03 +01:00
< li class = "nav-item" >
< a href = "#dns-providers" class = "nav-link" onclick = "showPage('dns-providers')" > DNS Providers< / a >
< / li >
< li class = "nav-item" >
< a href = "#tasks" class = "nav-link" onclick = "showPage('tasks')" > Tasks< / a >
< / li >
2025-09-18 02:56:59 +03:00
< li class = "nav-item" >
< a href = "#users" class = "nav-link" onclick = "showPage('users')" > Users< / a >
< / li >
< li class = "nav-item" >
< a href = "#tasks" class = "nav-link" onclick = "showPage('tasks')" > Tasks< / a >
< / li >
< / ul >
< / nav >
< / aside >
< main class = "main-content" >
< div id = "alert" class = "alert" > < / div >
<!-- Dashboard -->
< section id = "dashboard" class = "page-section active" >
< div class = "page-header" >
< h1 class = "page-title" > Dashboard< / h1 >
< p class = "page-subtitle" > Overview of your Xray infrastructure< / p >
< / div >
< div class = "stats-grid" >
< div class = "stat-card" >
< div class = "stat-value" id = "totalServers" > -< / div >
< div class = "stat-label" > Total Servers< / div >
< / div >
< div class = "stat-card" >
< div class = "stat-value" id = "onlineServers" > -< / div >
< div class = "stat-label" > Online Servers< / div >
< / div >
< div class = "stat-card" >
< div class = "stat-value" id = "totalCertificates" > -< / div >
< div class = "stat-label" > Certificates< / div >
< / div >
< div class = "stat-card" >
< div class = "stat-value" id = "totalUsers" > -< / div >
< div class = "stat-label" > Users< / div >
< / div >
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > Recent Activity< / h2 >
< p class = "card-subtitle" > Latest server status and activity< / p >
< / div >
< div id = "recentActivity" class = "loading" > Loading...< / div >
< / div >
< / section >
<!-- Servers -->
< section id = "servers" class = "page-section" >
< div class = "page-header" >
< h1 class = "page-title" > Servers< / h1 >
< p class = "page-subtitle" > Manage your Xray server instances< / p >
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > Add New Server< / h2 >
< p class = "card-subtitle" > Register a new Xray server instance< / p >
< / div >
< form id = "serverForm" class = "form-grid" >
< div class = "form-group" >
< label class = "form-label" for = "serverName" > Server Name *< / label >
< input type = "text" id = "serverName" class = "form-input" placeholder = "Enter server name" >
< / div >
< div class = "form-group" >
2025-09-23 14:17:32 +01:00
< label class = "form-label" for = "serverHostname" > Public Hostname *< / label >
2025-09-18 02:56:59 +03:00
< input type = "text" id = "serverHostname" class = "form-input" placeholder = "server.example.com" >
2025-09-23 14:17:32 +01:00
< small class = "form-help" > Hostname that clients will connect to< / small >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "serverGrpcHostname" > gRPC Hostname< / label >
< input type = "text" id = "serverGrpcHostname" class = "form-input" placeholder = "192.168.1.100 or leave empty to use public hostname" >
< small class = "form-help" > Internal address for gRPC API (optional, defaults to public hostname)< / small >
2025-09-18 02:56:59 +03:00
< / div >
< div class = "form-group" >
< label class = "form-label" for = "serverPort" > gRPC Port< / label >
< input type = "number" id = "serverPort" class = "form-input" placeholder = "2053" >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "serverCredentials" > API Credentials< / label >
< input type = "text" id = "serverCredentials" class = "form-input" placeholder = "Optional credentials" >
< / div >
< div class = "form-group" >
< button type = "submit" class = "btn btn-primary" > Add Server< / button >
< / div >
< / form >
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > Servers List< / h2 >
< / div >
< div id = "serversTable" class = "loading" > Loading...< / div >
< / div >
< / section >
<!-- Templates -->
< section id = "templates" class = "page-section" >
< div class = "page-header" >
< h1 class = "page-title" > Inbound Templates< / h1 >
< p class = "page-subtitle" > Manage reusable inbound configurations< / p >
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > Templates List< / h2 >
< / div >
< div id = "templatesTable" class = "loading" > Loading...< / div >
< / div >
< / section >
<!-- Certificates -->
< section id = "certificates" class = "page-section" >
< div class = "page-header" >
2025-09-24 00:30:03 +01:00
< div class = "page-header-content" >
< h1 class = "page-title" > SSL Certificates< / h1 >
< p class = "page-subtitle" > Manage SSL/TLS certificates for your servers< / p >
< / div >
< button class = "btn btn-primary" onclick = "showCreateCertificateModal()" > + Create Certificate< / button >
2025-09-18 02:56:59 +03:00
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > Certificates List< / h2 >
< / div >
< div id = "certificatesTable" class = "loading" > Loading...< / div >
< / div >
< / section >
2025-09-24 00:30:03 +01:00
<!-- DNS Providers -->
< section id = "dns-providers" class = "page-section" >
< div class = "page-header" >
< div class = "page-header-content" >
< h1 class = "page-title" > DNS Providers< / h1 >
< p class = "page-subtitle" > Manage DNS provider credentials for Let's Encrypt certificates< / p >
< / div >
< button class = "btn btn-primary" onclick = "showCreateDnsProviderModal()" > + Add DNS Provider< / button >
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > DNS Providers List< / h2 >
< / div >
< div id = "dnsProvidersTable" class = "loading" > Loading...< / div >
< / div >
< / section >
<!-- Tasks -->
< section id = "tasks" class = "page-section" >
< div class = "page-header" >
< div class = "page-header-content" >
< h1 class = "page-title" > Scheduled Tasks< / h1 >
< p class = "page-subtitle" > Monitor and manage background tasks< / p >
< / div >
< button class = "btn btn-secondary" onclick = "refreshTasks()" > 🔄 Refresh< / button >
< / div >
<!-- Task Summary Cards -->
< div class = "task-summary-grid" >
< div class = "summary-card" >
< div class = "summary-icon" > 📋< / div >
< div class = "summary-content" >
< h3 id = "totalTasks" > -< / h3 >
< p > Total Tasks< / p >
< / div >
< / div >
< div class = "summary-card" >
< div class = "summary-icon" > 🏃< / div >
< div class = "summary-content" >
< h3 id = "runningTasks" > -< / h3 >
< p > Running< / p >
< / div >
< / div >
< div class = "summary-card" >
< div class = "summary-icon" > ✅< / div >
< div class = "summary-content" >
< h3 id = "successTasks" > -< / h3 >
< p > Successful< / p >
< / div >
< / div >
< div class = "summary-card" >
< div class = "summary-icon" > ❌< / div >
< div class = "summary-content" >
< h3 id = "errorTasks" > -< / h3 >
< p > Failed< / p >
< / div >
< / div >
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > Tasks List< / h2 >
< / div >
< div id = "tasksTable" class = "loading" > Loading...< / div >
< / div >
< / section >
2025-09-18 02:56:59 +03:00
<!-- Users -->
< section id = "users" class = "page-section" >
< div class = "page-header" >
2025-09-24 00:30:03 +01:00
< div class = "page-header-content" >
< h1 class = "page-title" > Users< / h1 >
< p class = "page-subtitle" > Manage user accounts and access< / p >
< / div >
2025-09-23 14:17:32 +01:00
< button class = "btn btn-primary" onclick = "showCreateUserModal()" > + Create User< / button >
2025-09-18 02:56:59 +03:00
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > Users List< / h2 >
< / div >
< div id = "usersTable" class = "loading" > Loading...< / div >
< / div >
< / section >
< section id = "tasks" class = "page-section" >
< div class = "page-header" >
< h1 class = "page-title" > Background Tasks< / h1 >
< p class = "page-subtitle" > Monitor scheduled tasks and background jobs< / p >
< / div >
< div class = "card" >
< div class = "card-header" >
< h2 class = "card-title" > Active Tasks< / h2 >
< button class = "button button-outline" onclick = "refreshTasks()" >
< span class = "icon" > 🔄< / span >
Refresh
< / button >
< / div >
< div id = "tasksTable" class = "loading" > Loading...< / div >
< / div >
< / section >
< / main >
< / div >
< script >
const API_BASE = '/api';
let currentPage = 'dashboard';
// Navigation
function showPage(page) {
// Hide all pages
document.querySelectorAll('.page-section').forEach(section => {
section.classList.remove('active');
});
// Remove active class from all nav links
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
// Show selected page
document.getElementById(page).classList.add('active');
document.querySelector(`[onclick="showPage('${page}')"]`).classList.add('active');
currentPage = page;
// Load page data
loadPageData(page);
}
function loadPageData(page) {
switch(page) {
case 'dashboard':
loadDashboard();
break;
case 'servers':
loadServers();
break;
case 'templates':
loadTemplates();
break;
case 'certificates':
loadCertificates();
break;
2025-09-24 00:30:03 +01:00
case 'dns-providers':
loadDnsProviders();
break;
case 'tasks':
loadTasks();
break;
2025-09-18 02:56:59 +03:00
case 'users':
loadUsers();
break;
case 'tasks':
loadTasks();
break;
}
}
// Dashboard
async function loadDashboard() {
try {
// Load statistics
const [servers, certificates, users] = await Promise.all([
fetch(`${API_BASE}/servers`).then(r => r.json()),
fetch(`${API_BASE}/certificates`).then(r => r.json()),
fetch(`${API_BASE}/users`).then(r => r.json())
]);
document.getElementById('totalServers').textContent = servers.length;
document.getElementById('onlineServers').textContent = servers.filter(s => s.status === 'online').length;
document.getElementById('totalCertificates').textContent = certificates.length;
document.getElementById('totalUsers').textContent = users.users ? users.users.length : users.length;
// Recent activity
const activity = servers.slice(0, 5).map(server =>
`< p > Server "${server.name}" is ${server.status}< / p > `
).join('');
document.getElementById('recentActivity').innerHTML = activity || '< p > No recent activity< / p > ';
} catch (error) {
showAlert('Error loading dashboard: ' + error.message, 'error');
}
}
// Servers
async function loadServers() {
try {
const response = await fetch(`${API_BASE}/servers`);
const servers = await response.json();
if (servers.length === 0) {
document.getElementById('serversTable').innerHTML = '< div class = "empty-state" > < h3 > No servers found< / h3 > < p > Add your first server to get started< / p > < / div > ';
return;
}
const table = `
< table class = "table" >
< thead >
< tr >
< th > Name< / th >
2025-09-23 14:17:32 +01:00
< th > Public Hostname< / th >
< th > gRPC Address< / th >
2025-09-18 02:56:59 +03:00
< th > Status< / th >
< th > Actions< / th >
< / tr >
< / thead >
< tbody >
${servers.map(server => `
< tr >
< td > < strong > ${escapeHtml(server.name)}< / strong > < / td >
< td > ${escapeHtml(server.hostname)}< / td >
2025-09-23 14:17:32 +01:00
< td > ${escapeHtml(server.grpc_hostname)}:${server.grpc_port}< / td >
2025-09-18 02:56:59 +03:00
< td > < span class = "status-badge status-${server.status}" > ${server.status}< / span > < / td >
< td >
< div class = "actions" >
2025-09-23 14:17:32 +01:00
< button class = "btn btn-small btn-primary" onclick = "editServer('${server.id}')" > Edit< / button >
2025-09-18 02:56:59 +03:00
< button class = "btn btn-small btn-success" onclick = "testConnection('${server.id}')" > Test< / button >
< button class = "btn btn-small btn-danger" onclick = "deleteServer('${server.id}', '${escapeHtml(server.name)}')" > Delete< / button >
< / div >
< / td >
< / tr >
`).join('')}
< / tbody >
< / table >
`;
document.getElementById('serversTable').innerHTML = table;
} catch (error) {
document.getElementById('serversTable').innerHTML = '< div class = "empty-state" > < h3 > Error loading servers< / h3 > < p > ' + error.message + '< / p > < / div > ';
}
}
// Templates
async function loadTemplates() {
try {
const response = await fetch(`${API_BASE}/templates`);
const templates = await response.json();
if (templates.length === 0) {
document.getElementById('templatesTable').innerHTML = '< div class = "empty-state" > < h3 > No templates found< / h3 > < p > Templates help you quickly configure server inbounds< / p > < / div > ';
return;
}
const table = `
< table class = "table" >
< thead >
< tr >
< th > Name< / th >
< th > Protocol< / th >
< th > Default Port< / th >
< th > TLS Required< / th >
< th > Active< / th >
< th > Actions< / th >
< / tr >
< / thead >
< tbody >
${templates.map(template => `
< tr >
< td > < strong > ${escapeHtml(template.name)}< / strong > < / td >
< td > ${template.protocol}< / td >
< td > ${template.default_port}< / td >
< td > ${template.requires_tls ? 'Yes' : 'No'}< / td >
< td > < span class = "status-badge ${template.is_active ? 'status-online' : 'status-offline'}" > ${template.is_active ? 'Active' : 'Inactive'}< / span > < / td >
< td >
< div class = "actions" >
< button class = "btn btn-small btn-secondary" > Edit< / button >
< button class = "btn btn-small btn-danger" > Delete< / button >
< / div >
< / td >
< / tr >
`).join('')}
< / tbody >
< / table >
`;
document.getElementById('templatesTable').innerHTML = table;
} catch (error) {
document.getElementById('templatesTable').innerHTML = '< div class = "empty-state" > < h3 > Error loading templates< / h3 > < p > ' + error.message + '< / p > < / div > ';
}
}
// Certificates
async function loadCertificates() {
try {
const response = await fetch(`${API_BASE}/certificates`);
const certificates = await response.json();
if (certificates.length === 0) {
document.getElementById('certificatesTable').innerHTML = '< div class = "empty-state" > < h3 > No certificates found< / h3 > < p > Upload or generate SSL certificates for your servers< / p > < / div > ';
return;
}
const table = `
< table class = "table" >
< thead >
< tr >
< th > Name< / th >
< th > Domain< / th >
< th > Type< / th >
< th > Expires< / th >
< th > Auto Renew< / th >
< th > Actions< / th >
< / tr >
< / thead >
< tbody >
${certificates.map(cert => `
< tr >
< td > < strong > ${escapeHtml(cert.name)}< / strong > < / td >
< td > ${escapeHtml(cert.domain)}< / td >
< td > ${cert.cert_type}< / td >
< td > ${new Date(cert.expires_at).toLocaleDateString()}< / td >
< td > ${cert.auto_renew ? 'Yes' : 'No'}< / td >
< td >
< div class = "actions" >
< button class = "btn btn-small btn-secondary" > View< / button >
< button class = "btn btn-small btn-danger" > Delete< / button >
< / div >
< / td >
< / tr >
`).join('')}
< / tbody >
< / table >
`;
document.getElementById('certificatesTable').innerHTML = table;
} catch (error) {
document.getElementById('certificatesTable').innerHTML = '< div class = "empty-state" > < h3 > Error loading certificates< / h3 > < p > ' + error.message + '< / p > < / div > ';
}
}
// Users
async function loadUsers() {
try {
const response = await fetch(`${API_BASE}/users`);
const data = await response.json();
const users = data.users || data;
if (users.length === 0) {
document.getElementById('usersTable').innerHTML = '< div class = "empty-state" > < h3 > No users found< / h3 > < p > Create user accounts for access management< / p > < / div > ';
return;
}
const table = `
< table class = "table" >
< thead >
< tr >
< th > Name< / th >
< th > Comment< / th >
< th > Telegram ID< / th >
< th > Created< / th >
< th > Actions< / th >
< / tr >
< / thead >
< tbody >
${users.map(user => `
< tr >
< td > < strong > ${escapeHtml(user.name)}< / strong > < / td >
< td > ${escapeHtml(user.comment || '-')}< / td >
< td > ${user.telegram_id || '-'}< / td >
< td > ${new Date(user.created_at).toLocaleDateString()}< / td >
< td >
< div class = "actions" >
< button class = "btn btn-small btn-secondary" onclick = "editUserAccess('${user.id}', '${escapeHtml(user.name)}')" > Manage Access< / button >
< button class = "btn btn-small btn-danger" > Delete< / button >
< / div >
< / td >
< / tr >
`).join('')}
< / tbody >
< / table >
`;
document.getElementById('usersTable').innerHTML = table;
} catch (error) {
document.getElementById('usersTable').innerHTML = '< div class = "empty-state" > < h3 > Error loading users< / h3 > < p > ' + error.message + '< / p > < / div > ';
}
}
// Server form submission
document.getElementById('serverForm').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('serverName').value.trim();
const hostname = document.getElementById('serverHostname').value.trim();
2025-09-23 14:17:32 +01:00
const grpc_hostname = document.getElementById('serverGrpcHostname').value.trim();
2025-09-18 02:56:59 +03:00
const grpc_port = document.getElementById('serverPort').value;
const api_credentials = document.getElementById('serverCredentials').value.trim();
if (!name || !hostname) {
showAlert('Name and hostname are required', 'error');
return;
}
const serverData = { name, hostname };
2025-09-23 14:17:32 +01:00
if (grpc_hostname) serverData.grpc_hostname = grpc_hostname;
2025-09-18 02:56:59 +03:00
if (grpc_port) serverData.grpc_port = parseInt(grpc_port);
if (api_credentials) serverData.api_credentials = api_credentials;
try {
const response = await fetch(`${API_BASE}/servers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serverData)
});
if (!response.ok) throw new Error('Failed to create server');
showAlert('Server created successfully', 'success');
document.getElementById('serverForm').reset();
loadServers();
} catch (error) {
showAlert('Error creating server: ' + error.message, 'error');
}
});
// Server actions
async function testConnection(serverId) {
try {
const response = await fetch(`${API_BASE}/servers/${serverId}/test`, {
method: 'POST'
});
const result = await response.json();
if (result.connected) {
showAlert('Connection successful!', 'success');
} else {
showAlert('Connection failed', 'error');
}
} catch (error) {
showAlert('Error testing connection: ' + error.message, 'error');
}
}
2025-09-23 14:17:32 +01:00
async function editServer(serverId) {
try {
// Fetch server data
const response = await fetch(`${API_BASE}/servers/${serverId}`);
if (!response.ok) throw new Error('Failed to fetch server');
const server = await response.json();
// Create edit modal
const modalContent = `
< div class = "modal-overlay" onclick = "closeEditServerModal()" >
< div class = "modal-content" onclick = "event.stopPropagation()" >
< div class = "modal-header" >
< h2 > Edit Server: ${escapeHtml(server.name)}< / h2 >
< button class = "btn btn-small" onclick = "closeEditServerModal()" > Close< / button >
< / div >
< div class = "modal-body" >
< form id = "editServerForm" >
< input type = "hidden" id = "editServerId" value = "${server.id}" >
< div class = "form-group" >
< label class = "form-label" for = "editServerName" > Server Name *< / label >
< input type = "text" id = "editServerName" class = "form-input" value = "${escapeHtml(server.name)}" required >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "editServerHostname" > Public Hostname *< / label >
< input type = "text" id = "editServerHostname" class = "form-input" value = "${escapeHtml(server.hostname)}" required >
< small class = "form-help" > Hostname that clients will connect to< / small >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "editServerGrpcHostname" > gRPC Hostname< / label >
< input type = "text" id = "editServerGrpcHostname" class = "form-input" value = "${escapeHtml(server.grpc_hostname || '')}" placeholder = "Leave empty to use public hostname" >
< small class = "form-help" > Internal address for gRPC API (optional)< / small >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "editServerPort" > gRPC Port< / label >
< input type = "number" id = "editServerPort" class = "form-input" value = "${server.grpc_port}" >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "editServerCredentials" > API Credentials< / label >
< input type = "text" id = "editServerCredentials" class = "form-input" placeholder = "${server.has_credentials ? 'Leave empty to keep existing credentials' : 'Optional credentials'}" >
< / div >
< div class = "modal-actions" >
< button type = "submit" class = "btn btn-primary" > Update Server< / button >
< button type = "button" class = "btn btn-secondary" onclick = "closeEditServerModal()" > Cancel< / button >
< / div >
< / form >
< / div >
< / div >
< / div >
`;
document.body.insertAdjacentHTML('beforeend', modalContent);
// Handle form submission
document.getElementById('editServerForm').addEventListener('submit', async (e) => {
e.preventDefault();
await updateServer();
});
} catch (error) {
showAlert('Error loading server: ' + error.message, 'error');
}
}
function closeEditServerModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
async function updateServer() {
const serverId = document.getElementById('editServerId').value;
const name = document.getElementById('editServerName').value.trim();
const hostname = document.getElementById('editServerHostname').value.trim();
const grpc_hostname = document.getElementById('editServerGrpcHostname').value.trim();
const grpc_port = document.getElementById('editServerPort').value;
const api_credentials = document.getElementById('editServerCredentials').value.trim();
if (!name || !hostname) {
showAlert('Name and hostname are required', 'error');
return;
}
const serverData = { name, hostname };
if (grpc_hostname) serverData.grpc_hostname = grpc_hostname;
if (grpc_port) serverData.grpc_port = parseInt(grpc_port);
if (api_credentials) serverData.api_credentials = api_credentials;
try {
const response = await fetch(`${API_BASE}/servers/${serverId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serverData)
});
if (!response.ok) throw new Error('Failed to update server');
showAlert('Server updated successfully', 'success');
closeEditServerModal();
loadServers();
} catch (error) {
showAlert('Error updating server: ' + error.message, 'error');
}
}
2025-09-18 02:56:59 +03:00
async function deleteServer(serverId, serverName) {
if (!confirm(`Are you sure you want to delete server "${serverName}"?`)) return;
try {
const response = await fetch(`${API_BASE}/servers/${serverId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete server');
showAlert('Server deleted successfully', 'success');
loadServers();
} catch (error) {
showAlert('Error deleting server: ' + error.message, 'error');
}
}
// Utility functions
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = `alert alert-${type} show`;
setTimeout(() => {
alert.classList.remove('show');
}, 3000);
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// User Access Management
async function editUserAccess(userId, userName) {
try {
2025-09-23 14:17:32 +01:00
// First, load existing user access
const userAccessResponse = await fetch(`${API_BASE}/users/${userId}/access`);
const existingAccess = await userAccessResponse.ok ? await userAccessResponse.json() : [];
2025-09-18 02:56:59 +03:00
// Load servers and their inbounds
const serversResponse = await fetch(`${API_BASE}/servers`);
const servers = await serversResponse.json();
let modalContent = `
< div class = "modal-overlay" onclick = "closeUserAccessModal()" >
< div class = "modal-content" onclick = "event.stopPropagation()" >
< div class = "modal-header" >
< h2 > Manage Access: ${escapeHtml(userName)}< / h2 >
< button class = "btn btn-small" onclick = "closeUserAccessModal()" > Close< / button >
< / div >
< div class = "modal-body" >
< form id = "userAccessForm" >
< input type = "hidden" id = "editUserId" value = "${userId}" >
2025-09-23 14:17:32 +01:00
< input type = "hidden" id = "editUserName" value = "${escapeHtml(userName)}" >
2025-09-18 02:56:59 +03:00
`;
for (const server of servers) {
modalContent += `
< div class = "server-section" >
2025-09-23 14:17:32 +01:00
< h3 > ${escapeHtml(server.name)} (${escapeHtml(server.hostname)})< / h3 >
< p > < small > gRPC: ${escapeHtml(server.grpc_hostname)}:${server.grpc_port}< / small > < / p >
2025-09-18 02:56:59 +03:00
`;
try {
const inboundsResponse = await fetch(`${API_BASE}/servers/${server.id}/inbounds`);
const inbounds = await inboundsResponse.json();
if (inbounds.length > 0) {
for (const inbound of inbounds) {
2025-09-23 14:17:32 +01:00
// Check if user already has access to this inbound
const hasAccess = existingAccess.some(a =>
a.server_inbound_id === inbound.id
);
2025-09-18 02:56:59 +03:00
modalContent += `
< div class = "inbound-item" >
< label >
< input type = "checkbox" name = "access"
value="${server.id}|${inbound.id}"
data-server-id="${server.id}"
data-inbound-id="${inbound.id}"
data-server-name="${escapeHtml(server.name)}"
2025-09-23 14:17:32 +01:00
data-inbound-port="${inbound.port_override || 'default'}"
${hasAccess ? 'checked' : ''}>
${escapeHtml(inbound.template_name || 'Unknown Template')}
${hasAccess ? '< span class = "status-badge status-online" > Active< / span > ' : ''}
2025-09-18 02:56:59 +03:00
< / label >
< / div >
`;
}
} else {
modalContent += '< p > No inbounds configured< / p > ';
}
} catch (error) {
modalContent += '< p > Error loading inbounds< / p > ';
}
modalContent += '< / div > ';
}
modalContent += `
< div class = "modal-actions" >
< button type = "submit" class = "btn btn-primary" > Save Access< / button >
< button type = "button" class = "btn btn-secondary" onclick = "closeUserAccessModal()" > Cancel< / button >
< / div >
< / form >
< / div >
< / div >
< / div >
`;
document.body.insertAdjacentHTML('beforeend', modalContent);
// Handle form submission
document.getElementById('userAccessForm').addEventListener('submit', saveUserAccess);
} catch (error) {
showAlert('Error loading server data: ' + error.message, 'error');
}
}
function closeUserAccessModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
2025-09-23 14:17:32 +01:00
// Show create user modal
function showCreateUserModal() {
const modalContent = `
< div class = "modal-overlay" onclick = "closeCreateUserModal()" >
< div class = "modal-content" onclick = "event.stopPropagation()" >
< div class = "modal-header" >
< h2 > Create New User< / h2 >
< button class = "btn btn-small" onclick = "closeCreateUserModal()" > Close< / button >
< / div >
< div class = "modal-body" >
< form id = "createUserForm" >
< div class = "form-grid" >
< label >
Name: < span class = "required" > *< / span >
< input type = "text" name = "name" required placeholder = "User name" >
< / label >
< label >
Comment:
< input type = "text" name = "comment" placeholder = "Optional comment" >
< / label >
< label >
Telegram ID:
< input type = "number" name = "telegram_id" placeholder = "Optional Telegram ID" >
< / label >
< / div >
< div class = "modal-actions" >
< button type = "submit" class = "btn btn-primary" > Create User< / button >
< button type = "button" class = "btn btn-secondary" onclick = "closeCreateUserModal()" > Cancel< / button >
< / div >
< / form >
< / div >
< / div >
< / div >
`;
document.body.insertAdjacentHTML('beforeend', modalContent);
document.getElementById('createUserForm').addEventListener('submit', createUser);
}
function closeCreateUserModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
async function createUser(event) {
event.preventDefault();
const formData = new FormData(event.target);
const userData = {
name: formData.get('name'),
comment: formData.get('comment') || null,
telegram_id: formData.get('telegram_id') ? parseInt(formData.get('telegram_id')) : null
};
try {
const response = await fetch(`${API_BASE}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create user');
}
showAlert('User created successfully', 'success');
closeCreateUserModal();
loadUsers(); // Reload users table
} catch (error) {
showAlert('Error creating user: ' + error.message, 'error');
}
}
2025-09-18 02:56:59 +03:00
function generateUUID() {
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
document.getElementById('xrayUserId').value = uuid;
}
async function saveUserAccess(event) {
event.preventDefault();
const userId = document.getElementById('editUserId').value;
2025-09-23 14:17:32 +01:00
const userName = document.getElementById('editUserName').value;
2025-09-18 02:56:59 +03:00
2025-09-23 14:17:32 +01:00
const allCheckboxes = document.querySelectorAll('input[name="access"]');
2025-09-18 02:56:59 +03:00
try {
2025-09-23 14:17:32 +01:00
// Get current user access
const currentAccessResponse = await fetch(`${API_BASE}/users/${userId}/access`);
const currentAccess = await currentAccessResponse.ok ? await currentAccessResponse.json() : [];
// Process each checkbox
for (const checkbox of allCheckboxes) {
2025-09-18 02:56:59 +03:00
const serverId = checkbox.dataset.serverId;
const inboundId = checkbox.dataset.inboundId;
2025-09-23 14:17:32 +01:00
const currentlyHasAccess = currentAccess.some(a =>
a.server_inbound_id === inboundId
);
2025-09-18 02:56:59 +03:00
2025-09-23 14:17:32 +01:00
if (checkbox.checked & & !currentlyHasAccess) {
// Grant access - pass user_id to use existing user
const grantData = {
user_id: userId,
name: userName,
level: 0
};
const response = await fetch(`${API_BASE}/servers/${serverId}/inbounds/${inboundId}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(grantData)
});
2025-09-18 02:56:59 +03:00
2025-09-23 14:17:32 +01:00
if (!response.ok & & response.status !== 409) { // 409 = already exists
throw new Error(`Failed to grant access`);
}
} else if (!checkbox.checked & & currentlyHasAccess) {
// Revoke access
const accessRecord = currentAccess.find(a =>
a.server_inbound_id === inboundId
);
if (accessRecord) {
const response = await fetch(`${API_BASE}/servers/${serverId}/inbounds/${inboundId}/users/${accessRecord.id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to revoke access`);
}
}
2025-09-18 02:56:59 +03:00
}
}
showAlert('User access saved successfully', 'success');
closeUserAccessModal();
} catch (error) {
showAlert('Error saving user access: ' + error.message, 'error');
}
}
// Tasks
async function loadTasks() {
try {
// TODO: Add API endpoint for tasks when ready
// For now, show static task information
const tasksData = [
{
id: "xray_sync",
name: "Xray Synchronization",
description: "Synchronizes database state with xray servers",
schedule: "0 * * * * * (every minute)",
status: "running",
lastRun: new Date().toISOString(),
nextRun: new Date(Date.now() + 60000).toISOString(),
totalRuns: Math.floor(Math.random() * 50) + 10,
successCount: Math.floor(Math.random() * 45) + 8,
errorCount: Math.floor(Math.random() * 3),
lastDurationMs: Math.floor(Math.random() * 2000) + 500,
lastError: null
}
];
if (tasksData.length === 0) {
document.getElementById('tasksTable').innerHTML = '< div class = "empty-state" > < h3 > No tasks configured< / h3 > < p > Background tasks will appear here when configured< / p > < / div > ';
return;
}
const table = `
< table class = "table" >
< thead >
< tr >
< th > Task Name< / th >
< th > Schedule< / th >
< th > Status< / th >
< th > Last Run< / th >
< th > Next Run< / th >
< th > Performance< / th >
< th > Duration< / th >
< / tr >
< / thead >
< tbody >
${tasksData.map(task => {
const statusIcon = getTaskStatusIcon(task.status);
const successRate = task.totalRuns > 0 ? Math.round((task.successCount / task.totalRuns) * 100) : 0;
return `
< tr >
< td >
< strong > ${escapeHtml(task.name)}< / strong >
< br > < small class = "text-muted" > ${escapeHtml(task.description)}< / small >
< / td >
< td > < code > ${escapeHtml(task.schedule)}< / code > < / td >
< td >
< span class = "status ${getTaskStatusClass(task.status)}" >
${statusIcon} ${task.status}
< / span >
${task.lastError ? `< br > < small class = "text-muted" > Error: ${escapeHtml(task.lastError.substring(0, 50))}...< / small > ` : ''}
< / td >
< td > ${task.lastRun ? new Date(task.lastRun).toLocaleString() : '-'}< / td >
< td > ${task.nextRun ? new Date(task.nextRun).toLocaleString() : '-'}< / td >
< td >
< div class = "task-stats" >
< div > ✅ ${task.successCount} / ❌ ${task.errorCount}< / div >
< div class = "success-rate ${successRate >= 95 ? 'success-rate-good' : successRate >= 80 ? 'success-rate-ok' : 'success-rate-bad'}" >
${successRate}% success
< / div >
< / div >
< / td >
< td > ${task.lastDurationMs ? task.lastDurationMs + 'ms' : '-'}< / td >
< / tr >
`}).join('')}
< / tbody >
< / table >
`;
document.getElementById('tasksTable').innerHTML = table;
} catch (error) {
document.getElementById('tasksTable').innerHTML = '< div class = "error" > Failed to load tasks: ' + error.message + '< / div > ';
}
}
function getTaskStatusIcon(status) {
switch(status) {
case 'running': return '🔄';
case 'success': return '✅';
case 'error': return '❌';
case 'idle': return '⏸️';
default: return '❓';
}
}
function getTaskStatusClass(status) {
switch(status) {
case 'running': return 'status-running';
case 'success': return 'status-success';
case 'error': return 'status-error';
case 'idle': return 'status-idle';
default: return 'status-unknown';
}
}
function refreshTasks() {
document.getElementById('tasksTable').innerHTML = '< div class = "loading" > Loading...< / div > ';
loadTasks();
}
2025-09-24 00:30:03 +01:00
// DNS Providers
async function loadDnsProviders() {
try {
const response = await fetch(`${API_BASE}/dns-providers`);
const providers = await response.json();
if (providers.length === 0) {
document.getElementById('dnsProvidersTable').innerHTML = '< div class = "empty-state" > < h3 > No DNS providers found< / h3 > < p > Add DNS provider credentials to enable Let\'s Encrypt certificates< / p > < / div > ';
return;
}
const table = `
< table class = "table" >
< thead >
< tr >
< th > Name< / th >
< th > Provider Type< / th >
< th > Status< / th >
< th > Created< / th >
< th > Actions< / th >
< / tr >
< / thead >
< tbody >
${providers.map(provider => `
< tr >
< td > < strong > ${escapeHtml(provider.name)}< / strong > < / td >
< td > ${provider.provider_type}< / td >
< td > < span class = "status-badge ${provider.is_active ? 'status-online' : 'status-offline'}" > ${provider.is_active ? 'Active' : 'Inactive'}< / span > < / td >
< td > ${new Date(provider.created_at).toLocaleDateString()}< / td >
< td >
< div class = "actions" >
< button class = "btn btn-small btn-secondary" onclick = "editDnsProvider('${provider.id}')" > Edit< / button >
< button class = "btn btn-small btn-danger" onclick = "deleteDnsProvider('${provider.id}', '${escapeHtml(provider.name)}')" > Delete< / button >
< / div >
< / td >
< / tr >
`).join('')}
< / tbody >
< / table >
`;
document.getElementById('dnsProvidersTable').innerHTML = table;
} catch (error) {
document.getElementById('dnsProvidersTable').innerHTML = '< div class = "empty-state" > < h3 > Error loading DNS providers< / h3 > < p > ' + error.message + '< / p > < / div > ';
}
}
// Show create DNS provider modal
function showCreateDnsProviderModal() {
const modalContent = `
< div class = "modal-overlay" onclick = "closeCreateDnsProviderModal()" >
< div class = "modal-content" onclick = "event.stopPropagation()" >
< div class = "modal-header" >
< h2 > Add DNS Provider< / h2 >
< button class = "btn btn-small" onclick = "closeCreateDnsProviderModal()" > Close< / button >
< / div >
< div class = "modal-body" >
< form id = "createDnsProviderForm" >
< div class = "form-group" >
< label class = "form-label" for = "dnsProviderName" > Name *< / label >
< input type = "text" id = "dnsProviderName" class = "form-input" placeholder = "Enter provider name" required >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "dnsProviderType" > Provider Type *< / label >
< select id = "dnsProviderType" class = "form-select" required >
< option value = "" > Select provider type< / option >
< option value = "cloudflare" > Cloudflare< / option >
< / select >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "dnsProviderToken" > API Token *< / label >
< input type = "password" id = "dnsProviderToken" class = "form-input" placeholder = "Enter API token" required >
< small class = "form-help" > For Cloudflare: Create an API token with Zone:Read and Zone:DNS:Edit permissions< / small >
< / div >
< div class = "form-group" >
< label >
< input type = "checkbox" id = "dnsProviderActive" checked >
Active
< / label >
< / div >
< div class = "modal-actions" >
< button type = "submit" class = "btn btn-primary" > Add Provider< / button >
< button type = "button" class = "btn btn-secondary" onclick = "closeCreateDnsProviderModal()" > Cancel< / button >
< / div >
< / form >
< / div >
< / div >
< / div >
`;
document.body.insertAdjacentHTML('beforeend', modalContent);
document.getElementById('createDnsProviderForm').addEventListener('submit', createDnsProvider);
}
function closeCreateDnsProviderModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
async function createDnsProvider(event) {
event.preventDefault();
const name = document.getElementById('dnsProviderName').value.trim();
const provider_type = document.getElementById('dnsProviderType').value;
const api_token = document.getElementById('dnsProviderToken').value.trim();
const is_active = document.getElementById('dnsProviderActive').checked;
if (!name || !provider_type || !api_token) {
showAlert('All fields are required', 'error');
return;
}
const providerData = { name, provider_type, api_token, is_active };
try {
const response = await fetch(`${API_BASE}/dns-providers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(providerData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create DNS provider');
}
showAlert('DNS provider created successfully', 'success');
closeCreateDnsProviderModal();
loadDnsProviders();
} catch (error) {
showAlert('Error creating DNS provider: ' + error.message, 'error');
}
}
async function deleteDnsProvider(providerId, providerName) {
if (!confirm(`Are you sure you want to delete DNS provider "${providerName}"?`)) return;
try {
const response = await fetch(`${API_BASE}/dns-providers/${providerId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete DNS provider');
showAlert('DNS provider deleted successfully', 'success');
loadDnsProviders();
} catch (error) {
showAlert('Error deleting DNS provider: ' + error.message, 'error');
}
}
// Show create certificate modal
function showCreateCertificateModal() {
const modalContent = `
< div class = "modal-overlay" onclick = "closeCreateCertificateModal()" >
< div class = "modal-content" onclick = "event.stopPropagation()" >
< div class = "modal-header" >
< h2 > Create Certificate< / h2 >
< button class = "btn btn-small" onclick = "closeCreateCertificateModal()" > Close< / button >
< / div >
< div class = "modal-body" >
< form id = "createCertificateForm" >
< div class = "form-group" >
< label class = "form-label" for = "certName" > Certificate Name *< / label >
< input type = "text" id = "certName" class = "form-input" placeholder = "Enter certificate name" required >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "certDomain" > Domain *< / label >
< input type = "text" id = "certDomain" class = "form-input" placeholder = "example.com" required >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "certType" > Certificate Type *< / label >
< select id = "certType" class = "form-select" required onchange = "toggleCertificateFields()" >
< option value = "" > Select certificate type< / option >
< option value = "self_signed" > Self-Signed< / option >
< option value = "letsencrypt" > Let's Encrypt< / option >
< option value = "imported" > Imported< / option >
< / select >
< / div >
< div id = "letsencryptFields" style = "display: none;" >
< div class = "form-group" >
< label class = "form-label" for = "dnsProvider" > DNS Provider *< / label >
< select id = "dnsProvider" class = "form-select" >
< option value = "" > Loading...< / option >
< / select >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "acmeEmail" > ACME Email *< / label >
< input type = "email" id = "acmeEmail" class = "form-input" placeholder = "admin@example.com" >
< / div >
< / div >
< div id = "importedFields" style = "display: none;" >
< div class = "form-group" >
< label class = "form-label" for = "certPem" > Certificate PEM *< / label >
< textarea id = "certPem" class = "form-input" rows = "8" placeholder = "-----BEGIN CERTIFICATE-----" > < / textarea >
< / div >
< div class = "form-group" >
< label class = "form-label" for = "keyPem" > Private Key PEM *< / label >
< textarea id = "keyPem" class = "form-input" rows = "8" placeholder = "-----BEGIN PRIVATE KEY-----" > < / textarea >
< / div >
< / div >
< div class = "form-group" >
< label >
< input type = "checkbox" id = "autoRenew" checked >
Auto-renew certificate
< / label >
< / div >
< div class = "modal-actions" >
< button type = "submit" class = "btn btn-primary" > Create Certificate< / button >
< button type = "button" class = "btn btn-secondary" onclick = "closeCreateCertificateModal()" > Cancel< / button >
< / div >
< / form >
< / div >
< / div >
< / div >
`;
document.body.insertAdjacentHTML('beforeend', modalContent);
document.getElementById('createCertificateForm').addEventListener('submit', createCertificate);
loadDnsProvidersForSelect();
}
function closeCreateCertificateModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
function toggleCertificateFields() {
const certType = document.getElementById('certType').value;
const letsencryptFields = document.getElementById('letsencryptFields');
const importedFields = document.getElementById('importedFields');
letsencryptFields.style.display = certType === 'letsencrypt' ? 'block' : 'none';
importedFields.style.display = certType === 'imported' ? 'block' : 'none';
}
async function loadDnsProvidersForSelect() {
try {
const response = await fetch(`${API_BASE}/dns-providers/cloudflare/active`);
const providers = await response.json();
const select = document.getElementById('dnsProvider');
select.innerHTML = providers.length > 0
? providers.map(p => `< option value = "${p.id}" > ${escapeHtml(p.name)}< / option > `).join('')
: '< option value = "" > No active Cloudflare providers< / option > ';
} catch (error) {
const select = document.getElementById('dnsProvider');
select.innerHTML = '< option value = "" > Error loading providers< / option > ';
}
}
async function createCertificate(event) {
event.preventDefault();
const name = document.getElementById('certName').value.trim();
const domain = document.getElementById('certDomain').value.trim();
const cert_type = document.getElementById('certType').value;
const auto_renew = document.getElementById('autoRenew').checked;
if (!name || !domain || !cert_type) {
showAlert('Name, domain, and certificate type are required', 'error');
return;
}
const certData = { name, domain, cert_type, auto_renew, certificate_pem: '', private_key: '' };
if (cert_type === 'letsencrypt') {
const dns_provider_id = document.getElementById('dnsProvider').value;
const acme_email = document.getElementById('acmeEmail').value.trim();
if (!dns_provider_id || !acme_email) {
showAlert('DNS provider and ACME email are required for Let\'s Encrypt certificates', 'error');
return;
}
certData.dns_provider_id = dns_provider_id;
certData.acme_email = acme_email;
} else if (cert_type === 'imported') {
const certificate_pem = document.getElementById('certPem').value.trim();
const private_key = document.getElementById('keyPem').value.trim();
if (!certificate_pem || !private_key) {
showAlert('Certificate and private key PEM are required for imported certificates', 'error');
return;
}
certData.certificate_pem = certificate_pem;
certData.private_key = private_key;
}
try {
showAlert('Creating certificate...', 'success');
const response = await fetch(`${API_BASE}/certificates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(certData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create certificate');
}
showAlert('Certificate created successfully', 'success');
closeCreateCertificateModal();
loadCertificates();
} catch (error) {
showAlert('Error creating certificate: ' + error.message, 'error');
}
}
// Tasks
async function loadTasks() {
try {
const response = await fetch(`${API_BASE}/tasks`);
const data = await response.json();
// Update summary cards
document.getElementById('totalTasks').textContent = data.summary.total_tasks;
document.getElementById('runningTasks').textContent = data.summary.running_tasks;
document.getElementById('successTasks').textContent = data.summary.successful_tasks;
document.getElementById('errorTasks').textContent = data.summary.failed_tasks;
if (Object.keys(data.tasks).length === 0) {
document.getElementById('tasksTable').innerHTML = '< div class = "empty-state" > < h3 > No tasks found< / h3 > < p > No scheduled tasks are configured< / p > < / div > ';
return;
}
const tasksHtml = Object.entries(data.tasks).map(([taskId, task]) => {
const statusClass = task.status.toLowerCase();
const lastRun = task.last_run ? new Date(task.last_run).toLocaleString() : 'Never';
const nextRun = task.next_run ? new Date(task.next_run).toLocaleString() : 'Not scheduled';
const duration = task.last_duration_ms ? `${task.last_duration_ms}ms` : '-';
const successRate = task.total_runs > 0 ? Math.round((task.success_count / task.total_runs) * 100) : 0;
return `
< div class = "task-item" >
< div class = "task-header" >
< div class = "task-info" >
< h3 > ${escapeHtml(task.name)}< / h3 >
< p class = "task-description" > ${escapeHtml(task.description)}< / p >
< div class = "task-schedule" > 📅 ${escapeHtml(task.schedule)}< / div >
< / div >
< div class = "task-status-container" >
< span class = "task-status ${statusClass}" > ${task.status}< / span >
< div class = "task-actions" >
< button class = "btn btn-primary btn-small" onclick = "triggerTask('${taskId}')" > ▶️ Run Now< / button >
< button class = "btn btn-secondary btn-small" onclick = "refreshTasks()" > 🔄 Refresh< / button >
< / div >
< / div >
< / div >
< div class = "task-stats" >
< div class = "stat" >
< label > Last Run:< / label >
< span > ${lastRun}< / span >
< / div >
< div class = "stat" >
< label > Next Run:< / label >
< span > ${nextRun}< / span >
< / div >
< div class = "stat" >
< label > Total Runs:< / label >
< span > ${task.total_runs}< / span >
< / div >
< div class = "stat" >
< label > Success Rate:< / label >
< span > ${successRate}% (${task.success_count}/${task.total_runs})< / span >
< / div >
< div class = "stat" >
< label > Last Duration:< / label >
< span > ${duration}< / span >
< / div >
${task.last_error ? `
< div class = "stat error" >
< label > Last Error:< / label >
< span > ${escapeHtml(task.last_error)}< / span >
< / div >
` : ''}
< / div >
< / div >
`;
}).join('');
document.getElementById('tasksTable').innerHTML = `
< div class = "tasks-list" >
${tasksHtml}
< / div >
`;
} catch (error) {
document.getElementById('tasksTable').innerHTML = '< div class = "empty-state" > < h3 > Error loading tasks< / h3 > < p > ' + error.message + '< / p > < / div > ';
}
}
async function refreshTasks() {
loadTasks();
}
async function triggerTask(taskId) {
try {
const response = await fetch(`${API_BASE}/tasks/${taskId}/trigger`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to trigger task');
}
const result = await response.json();
showAlert(result.message, 'success');
// Refresh tasks after a short delay to show updated status
setTimeout(() => {
loadTasks();
}, 1000);
} catch (error) {
showAlert('Error triggering task: ' + error.message, 'error');
}
}
2025-09-18 02:56:59 +03:00
// Initialize
loadPageData('dashboard');
< / script >
< / body >
< / html >