mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-10-24 09:19:09 +00:00
3369 lines
134 KiB
HTML
3369 lines
134 KiB
HTML
<!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;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.page-header-content {
|
|
flex: 1;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* Telegram Bot Styles */
|
|
.status-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.status-dot.status-active {
|
|
background: #34c759;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
.status-dot.status-inactive {
|
|
background: #8e8e93;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(52, 199, 89, 0.7);
|
|
}
|
|
70% {
|
|
box-shadow: 0 0 0 10px rgba(52, 199, 89, 0);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(52, 199, 89, 0);
|
|
}
|
|
}
|
|
|
|
.status-text {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.form-input-group {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.form-input-group .form-input {
|
|
flex: 1;
|
|
}
|
|
|
|
.form-input-group .button {
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.form-checkbox {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
cursor: pointer;
|
|
padding: 8px 0;
|
|
}
|
|
|
|
.form-checkbox input[type="checkbox"] {
|
|
width: 18px;
|
|
height: 18px;
|
|
margin: 0;
|
|
}
|
|
|
|
.admin-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.admin-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.admin-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.admin-name {
|
|
font-weight: 500;
|
|
color: #1d1d1f;
|
|
margin: 0 0 4px 0;
|
|
}
|
|
|
|
.admin-telegram-id {
|
|
font-size: 12px;
|
|
color: #6e6e73;
|
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
|
}
|
|
|
|
.user-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.user-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.user-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.user-name {
|
|
font-weight: 500;
|
|
color: #1d1d1f;
|
|
margin: 0 0 4px 0;
|
|
}
|
|
|
|
.user-telegram-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: #6e6e73;
|
|
}
|
|
|
|
.telegram-connected {
|
|
color: #34c759;
|
|
}
|
|
|
|
.telegram-not-connected {
|
|
color: #8e8e93;
|
|
}
|
|
|
|
.bot-info {
|
|
padding: 16px;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
margin: 12px 0;
|
|
}
|
|
|
|
.bot-info-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.bot-info-item:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.bot-info-label {
|
|
font-weight: 500;
|
|
color: #6e6e73;
|
|
}
|
|
|
|
.bot-info-value {
|
|
color: #1d1d1f;
|
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
|
}
|
|
|
|
/* User Requests Styles */
|
|
.request-cards {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.request-card {
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
background: #fafafa;
|
|
}
|
|
|
|
.request-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.request-header h4 {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.request-info {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.request-info p {
|
|
margin: 8px 0;
|
|
color: #666;
|
|
}
|
|
|
|
.request-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.badge-warning {
|
|
background: #ffc107;
|
|
color: #000;
|
|
}
|
|
|
|
.badge-success {
|
|
background: #28a745;
|
|
color: white;
|
|
}
|
|
|
|
.badge-danger {
|
|
background: #dc3545;
|
|
color: white;
|
|
}
|
|
|
|
.badge-secondary {
|
|
background: #6c757d;
|
|
color: white;
|
|
}
|
|
|
|
.button-success {
|
|
background: #28a745;
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.button-success:hover {
|
|
background: #218838;
|
|
}
|
|
|
|
.button-danger {
|
|
background: #dc3545;
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.button-danger:hover {
|
|
background: #c82333;
|
|
}
|
|
|
|
.button-small {
|
|
padding: 6px 12px;
|
|
font-size: 14px;
|
|
}
|
|
</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>
|
|
<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>
|
|
<li class="nav-item">
|
|
<a href="#users" class="nav-link" onclick="showPage('users')">Users</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a href="#telegram" class="nav-link" onclick="showPage('telegram')">Telegram Bot</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a href="#user-requests" class="nav-link" onclick="showPage('user-requests')">User Requests</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">
|
|
<label class="form-label" for="serverHostname">Public Hostname *</label>
|
|
<input type="text" id="serverHostname" class="form-input" placeholder="server.example.com">
|
|
<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>
|
|
</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">
|
|
<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>
|
|
</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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Users -->
|
|
<section id="users" class="page-section">
|
|
<div class="page-header">
|
|
<div class="page-header-content">
|
|
<h1 class="page-title">Users</h1>
|
|
<p class="page-subtitle">Manage user accounts and access</p>
|
|
</div>
|
|
<button class="btn btn-primary" onclick="showCreateUserModal()">+ Create User</button>
|
|
</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>
|
|
|
|
<section id="telegram" class="page-section">
|
|
<div class="page-header">
|
|
<h1 class="page-title">Telegram Bot</h1>
|
|
<p class="page-subtitle">Configure and manage Telegram bot integration</p>
|
|
</div>
|
|
|
|
<!-- Bot Status Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Bot Status</h2>
|
|
<div id="botStatusIndicator" class="status-indicator">
|
|
<span class="status-dot status-inactive"></span>
|
|
<span class="status-text">Inactive</span>
|
|
</div>
|
|
</div>
|
|
<div id="botStatusInfo" class="loading">Loading...</div>
|
|
</div>
|
|
|
|
<!-- Bot Configuration Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Configuration</h2>
|
|
<div class="card-actions">
|
|
<button id="saveConfigBtn" class="button button-primary" onclick="saveTelegramConfig()" disabled>
|
|
<span class="icon">💾</span>
|
|
Save Configuration
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<form id="telegramConfigForm" class="form">
|
|
<div class="form-group">
|
|
<label for="botToken" class="form-label">Bot Token</label>
|
|
<div class="form-input-group">
|
|
<input type="password" id="botToken" class="form-input" placeholder="Enter bot token from @BotFather">
|
|
<button type="button" class="button button-outline" onclick="toggleTokenVisibility()">
|
|
<span class="icon">👁</span>
|
|
</button>
|
|
</div>
|
|
<div class="form-help">
|
|
Get your bot token from @BotFather on Telegram
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-checkbox">
|
|
<input type="checkbox" id="botActive" onchange="onBotActiveChange()">
|
|
<span class="checkmark"></span>
|
|
Enable Bot
|
|
</label>
|
|
<div class="form-help">
|
|
When enabled, bot will start polling for messages
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admins Management Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Bot Administrators</h2>
|
|
<button class="button button-outline" onclick="refreshAdmins()">
|
|
<span class="icon">🔄</span>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div id="adminsTable" class="loading">Loading...</div>
|
|
</div>
|
|
|
|
<!-- Admin Management Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Admin Management</h2>
|
|
<div class="form-input-group">
|
|
<input type="text" id="userSearchInput" class="form-input" placeholder="Search users by name, ID, or Telegram ID" style="min-width: 300px;">
|
|
<button class="button button-outline" onclick="searchUsers()">
|
|
<span class="icon">🔍</span>
|
|
Search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted">Search for users and manage admin privileges. Only users connected to Telegram can be promoted to admin.</p>
|
|
<div id="userSearchResults"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Users List Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">All Users</h2>
|
|
<button class="button button-outline" onclick="refreshTelegramUsers()">
|
|
<span class="icon">🔄</span>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div id="telegramUsersTable" class="loading">Loading...</div>
|
|
</div>
|
|
|
|
<!-- Test Message Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Send Test Message</h2>
|
|
</div>
|
|
<div class="card-body">
|
|
<form id="testMessageForm" class="form">
|
|
<div class="form-group">
|
|
<label for="testChatId" class="form-label">Chat ID</label>
|
|
<input type="number" id="testChatId" class="form-input" placeholder="Enter chat ID">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="testMessage" class="form-label">Message</label>
|
|
<textarea id="testMessage" class="form-input" rows="3" placeholder="Enter test message"></textarea>
|
|
</div>
|
|
|
|
<button type="submit" class="button button-primary">
|
|
<span class="icon">📤</span>
|
|
Send Message
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- User Requests -->
|
|
<section id="user-requests" class="page-section">
|
|
<div class="page-header">
|
|
<h1 class="page-title">User Requests</h1>
|
|
<p class="page-subtitle">Manage access requests from Telegram users</p>
|
|
</div>
|
|
|
|
<!-- Request Status Overview -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Request Overview</h2>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="stats-grid" id="requestStats">
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="pendingRequests">0</div>
|
|
<div class="stat-label">Pending</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="approvedRequests">0</div>
|
|
<div class="stat-label">Approved</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="declinedRequests">0</div>
|
|
<div class="stat-label">Declined</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pending Requests -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Pending Requests</h2>
|
|
<button class="button button-outline" onclick="loadUserRequests()">
|
|
<span class="icon">🔄</span>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div id="pendingRequestsTable" class="loading">Loading...</div>
|
|
</div>
|
|
|
|
<!-- All Requests -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">All Requests</h2>
|
|
</div>
|
|
<div id="allRequestsTable" 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;
|
|
case 'dns-providers':
|
|
loadDnsProviders();
|
|
break;
|
|
case 'tasks':
|
|
loadTasks();
|
|
break;
|
|
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>
|
|
<th>Public Hostname</th>
|
|
<th>gRPC Address</th>
|
|
<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>
|
|
<td>${escapeHtml(server.grpc_hostname)}:${server.grpc_port}</td>
|
|
<td><span class="status-badge status-${server.status}">${server.status}</span></td>
|
|
<td>
|
|
<div class="actions">
|
|
<button class="btn btn-small btn-primary" onclick="editServer('${server.id}')">Edit</button>
|
|
<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();
|
|
const grpc_hostname = document.getElementById('serverGrpcHostname').value.trim();
|
|
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 };
|
|
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`, {
|
|
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');
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// First, load existing user access
|
|
const userAccessResponse = await fetch(`${API_BASE}/users/${userId}/access`);
|
|
const existingAccess = await userAccessResponse.ok ? await userAccessResponse.json() : [];
|
|
|
|
// 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}">
|
|
<input type="hidden" id="editUserName" value="${escapeHtml(userName)}">
|
|
`;
|
|
|
|
for (const server of servers) {
|
|
modalContent += `
|
|
<div class="server-section">
|
|
<h3>${escapeHtml(server.name)} (${escapeHtml(server.hostname)})</h3>
|
|
<p><small>gRPC: ${escapeHtml(server.grpc_hostname)}:${server.grpc_port}</small></p>
|
|
`;
|
|
|
|
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) {
|
|
// Check if user already has access to this inbound
|
|
const hasAccess = existingAccess.some(a =>
|
|
a.server_inbound_id === inbound.id
|
|
);
|
|
|
|
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)}"
|
|
data-inbound-port="${inbound.port_override || 'default'}"
|
|
${hasAccess ? 'checked' : ''}>
|
|
${escapeHtml(inbound.template_name || 'Unknown Template')}
|
|
${hasAccess ? '<span class="status-badge status-online">Active</span>' : ''}
|
|
</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();
|
|
}
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
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;
|
|
const userName = document.getElementById('editUserName').value;
|
|
|
|
const allCheckboxes = document.querySelectorAll('input[name="access"]');
|
|
|
|
try {
|
|
// 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) {
|
|
const serverId = checkbox.dataset.serverId;
|
|
const inboundId = checkbox.dataset.inboundId;
|
|
|
|
const currentlyHasAccess = currentAccess.some(a =>
|
|
a.server_inbound_id === inboundId
|
|
);
|
|
|
|
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)
|
|
});
|
|
|
|
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`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
// Telegram Bot Functions
|
|
let currentTelegramConfig = null;
|
|
|
|
async function loadTelegram() {
|
|
await loadBotStatus();
|
|
await loadTelegramConfig();
|
|
await loadAdmins();
|
|
await loadTelegramUsers();
|
|
}
|
|
|
|
async function loadBotStatus() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/telegram/status`);
|
|
if (response.ok) {
|
|
const status = await response.json();
|
|
updateBotStatusUI(status);
|
|
} else {
|
|
updateBotStatusUI({ is_running: false, bot_info: null });
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading bot status:', error);
|
|
updateBotStatusUI({ is_running: false, bot_info: null });
|
|
}
|
|
}
|
|
|
|
function updateBotStatusUI(status) {
|
|
const indicator = document.getElementById('botStatusIndicator');
|
|
const statusInfo = document.getElementById('botStatusInfo');
|
|
|
|
const dot = indicator.querySelector('.status-dot');
|
|
const text = indicator.querySelector('.status-text');
|
|
|
|
if (status.is_running) {
|
|
dot.className = 'status-dot status-active';
|
|
text.textContent = 'Active';
|
|
|
|
if (status.bot_info) {
|
|
statusInfo.innerHTML = `
|
|
<div class="bot-info">
|
|
<div class="bot-info-item">
|
|
<span class="bot-info-label">Username:</span>
|
|
<span class="bot-info-value">@${status.bot_info.username}</span>
|
|
</div>
|
|
<div class="bot-info-item">
|
|
<span class="bot-info-label">Name:</span>
|
|
<span class="bot-info-value">${status.bot_info.first_name}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
} else {
|
|
dot.className = 'status-dot status-inactive';
|
|
text.textContent = 'Inactive';
|
|
statusInfo.innerHTML = '<p class="empty-state-text">Bot is not running</p>';
|
|
}
|
|
}
|
|
|
|
async function loadTelegramConfig() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/telegram/config`);
|
|
if (response.ok) {
|
|
currentTelegramConfig = await response.json();
|
|
updateConfigForm(currentTelegramConfig);
|
|
} else if (response.status === 404) {
|
|
currentTelegramConfig = null;
|
|
updateConfigForm(null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading config:', error);
|
|
currentTelegramConfig = null;
|
|
updateConfigForm(null);
|
|
}
|
|
}
|
|
|
|
function updateConfigForm(config) {
|
|
const botTokenInput = document.getElementById('botToken');
|
|
const botActiveCheckbox = document.getElementById('botActive');
|
|
const saveBtn = document.getElementById('saveConfigBtn');
|
|
|
|
if (config) {
|
|
botTokenInput.value = '••••••••••••••••'; // Masked token
|
|
botActiveCheckbox.checked = config.is_active;
|
|
} else {
|
|
botTokenInput.value = '';
|
|
botActiveCheckbox.checked = false;
|
|
}
|
|
|
|
saveBtn.disabled = false;
|
|
}
|
|
|
|
function toggleTokenVisibility() {
|
|
const tokenInput = document.getElementById('botToken');
|
|
const button = event.target.closest('button');
|
|
|
|
if (tokenInput.type === 'password') {
|
|
tokenInput.type = 'text';
|
|
button.innerHTML = '<span class="icon">🙈</span>';
|
|
} else {
|
|
tokenInput.type = 'password';
|
|
button.innerHTML = '<span class="icon">👁</span>';
|
|
}
|
|
}
|
|
|
|
function onBotActiveChange() {
|
|
const checkbox = document.getElementById('botActive');
|
|
const tokenInput = document.getElementById('botToken');
|
|
|
|
if (checkbox.checked && !tokenInput.value) {
|
|
showAlert('Please enter a bot token first', 'warning');
|
|
checkbox.checked = false;
|
|
}
|
|
}
|
|
|
|
async function saveTelegramConfig() {
|
|
const botToken = document.getElementById('botToken').value;
|
|
const isActive = document.getElementById('botActive').checked;
|
|
const saveBtn = document.getElementById('saveConfigBtn');
|
|
|
|
if (!botToken || botToken === '••••••••••••••••') {
|
|
showAlert('Please enter a valid bot token', 'error');
|
|
return;
|
|
}
|
|
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = 'Saving...';
|
|
|
|
try {
|
|
const method = currentTelegramConfig ? 'PUT' : 'POST';
|
|
const url = currentTelegramConfig ?
|
|
`${API_BASE}/telegram/config/${currentTelegramConfig.id}` :
|
|
`${API_BASE}/telegram/config`;
|
|
|
|
const response = await fetch(url, {
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
bot_token: botToken,
|
|
is_active: isActive
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
showAlert('Configuration saved successfully', 'success');
|
|
await loadTelegramConfig();
|
|
await loadBotStatus();
|
|
} else {
|
|
const error = await response.text();
|
|
showAlert('Error saving configuration: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showAlert('Error saving configuration: ' + error.message, 'error');
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = '<span class="icon">💾</span> Save Configuration';
|
|
}
|
|
}
|
|
|
|
async function loadAdmins() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/telegram/admins`);
|
|
if (response.ok) {
|
|
const admins = await response.json();
|
|
renderAdmins(admins);
|
|
}
|
|
} catch (error) {
|
|
document.getElementById('adminsTable').innerHTML = '<div class="empty-state"><h3>Error loading admins</h3></div>';
|
|
}
|
|
}
|
|
|
|
function renderAdmins(admins) {
|
|
const container = document.getElementById('adminsTable');
|
|
|
|
if (admins.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><h3>No administrators</h3><p>Add administrators to manage the bot</p></div>';
|
|
return;
|
|
}
|
|
|
|
const adminsHtml = admins.map(admin => `
|
|
<div class="admin-item">
|
|
<div class="admin-info">
|
|
<h4 class="admin-name">${admin.name}</h4>
|
|
<div class="admin-telegram-id">${admin.telegram_id ? `ID: ${admin.telegram_id}` : 'No Telegram ID'}</div>
|
|
</div>
|
|
<div class="admin-actions">
|
|
<button class="button button-outline button-small" onclick="removeAdmin('${admin.user_id}')">
|
|
<span class="icon">❌</span>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = `<div class="admins-list">${adminsHtml}</div>`;
|
|
}
|
|
|
|
async function loadTelegramUsers() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/users`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const users = data.users || data; // Handle both paginated and direct array responses
|
|
renderTelegramUsers(users);
|
|
} else {
|
|
const errorText = await response.text();
|
|
console.error('Error response:', response.status, errorText);
|
|
document.getElementById('telegramUsersTable').innerHTML =
|
|
`<div class="empty-state"><h3>Error loading users</h3><p>Status: ${response.status}</p><p>${errorText}</p></div>`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Network error:', error);
|
|
document.getElementById('telegramUsersTable').innerHTML =
|
|
`<div class="empty-state"><h3>Error loading users</h3><p>Network error: ${error.message}</p></div>`;
|
|
}
|
|
}
|
|
|
|
function renderTelegramUsers(users) {
|
|
const container = document.getElementById('telegramUsersTable');
|
|
|
|
if (users.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><h3>No users</h3></div>';
|
|
return;
|
|
}
|
|
|
|
const usersHtml = users.map(user => `
|
|
<div class="user-item">
|
|
<div class="user-info">
|
|
<h4 class="user-name">${user.name}</h4>
|
|
<div class="user-telegram-status">
|
|
${user.telegram_id ?
|
|
`<span class="telegram-connected">📱 Connected (ID: ${user.telegram_id})</span>` :
|
|
'<span class="telegram-not-connected">📱 Not connected</span>'
|
|
}
|
|
${user.is_telegram_admin ? '<span class="admin-badge">👑 Admin</span>' : ''}
|
|
</div>
|
|
</div>
|
|
<div class="user-actions">
|
|
${user.telegram_id && !user.is_telegram_admin ?
|
|
`<button class="button button-primary button-small" onclick="makeAdmin('${user.id}')">
|
|
<span class="icon">👑</span>
|
|
Make Admin
|
|
</button>` : ''
|
|
}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = `<div class="users-list">${usersHtml}</div>`;
|
|
}
|
|
|
|
async function makeAdmin(userId) {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/telegram/admins/${userId}`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
if (response.ok) {
|
|
showAlert('User promoted to admin', 'success');
|
|
await loadAdmins();
|
|
await loadTelegramUsers();
|
|
} else {
|
|
showAlert('Error promoting user to admin', 'error');
|
|
}
|
|
} catch (error) {
|
|
showAlert('Error promoting user: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function removeAdmin(userId) {
|
|
if (!confirm('Are you sure you want to remove admin privileges?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/telegram/admins/${userId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
showAlert('Admin privileges removed', 'success');
|
|
await loadAdmins();
|
|
await loadTelegramUsers();
|
|
} else {
|
|
showAlert('Error removing admin privileges', 'error');
|
|
}
|
|
} catch (error) {
|
|
showAlert('Error removing admin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function refreshAdmins() {
|
|
await loadAdmins();
|
|
}
|
|
|
|
async function refreshTelegramUsers() {
|
|
await loadTelegramUsers();
|
|
}
|
|
|
|
async function searchUsers() {
|
|
const query = document.getElementById('userSearchInput').value.trim();
|
|
if (!query) {
|
|
document.getElementById('userSearchResults').innerHTML = '<p class="text-muted">Enter a search term to find users</p>';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}`);
|
|
if (response.ok) {
|
|
const users = await response.json();
|
|
renderSearchResults(users);
|
|
} else {
|
|
document.getElementById('userSearchResults').innerHTML = '<div class="empty-state"><h3>Error searching users</h3></div>';
|
|
}
|
|
} catch (error) {
|
|
document.getElementById('userSearchResults').innerHTML = '<div class="empty-state"><h3>Search failed</h3></div>';
|
|
}
|
|
}
|
|
|
|
function renderSearchResults(users) {
|
|
const container = document.getElementById('userSearchResults');
|
|
|
|
if (users.length === 0) {
|
|
container.innerHTML = '<div class="empty-state"><h3>No users found</h3><p>Try a different search term</p></div>';
|
|
return;
|
|
}
|
|
|
|
const usersHtml = users.map(user => `
|
|
<div class="user-item" style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 12px;">
|
|
<div class="user-info">
|
|
<h4 class="user-name">${user.name}</h4>
|
|
<div class="user-details" style="margin-top: 8px;">
|
|
<p style="margin: 4px 0; font-size: 14px; color: #6b7280;">ID: ${user.id}</p>
|
|
${user.telegram_id ?
|
|
`<p style="margin: 4px 0; font-size: 14px; color: #6b7280;">📱 Telegram ID: ${user.telegram_id}</p>` :
|
|
'<p style="margin: 4px 0; font-size: 14px; color: #ef4444;">📱 Not connected to Telegram</p>'
|
|
}
|
|
${user.is_telegram_admin ?
|
|
'<p style="margin: 4px 0; font-size: 14px; color: #059669;">👑 Current Admin</p>' :
|
|
'<p style="margin: 4px 0; font-size: 14px; color: #6b7280;">Regular User</p>'
|
|
}
|
|
${user.comment ? `<p style="margin: 4px 0; font-size: 14px; color: #6b7280;">Comment: ${user.comment}</p>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="user-actions" style="margin-top: 12px;">
|
|
${user.telegram_id && !user.is_telegram_admin ?
|
|
`<button class="button button-primary" onclick="makeAdmin('${user.id}')" style="margin-right: 8px;">
|
|
<span class="icon">👑</span>
|
|
Make Admin
|
|
</button>` : ''
|
|
}
|
|
${user.telegram_id && user.is_telegram_admin ?
|
|
`<button class="button button-danger" onclick="removeAdmin('${user.id}')">
|
|
<span class="icon">👑</span>
|
|
Remove Admin
|
|
</button>` : ''
|
|
}
|
|
${!user.telegram_id ?
|
|
'<span style="color: #6b7280; font-size: 14px;">User must connect to Telegram first</span>' : ''
|
|
}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = usersHtml;
|
|
}
|
|
|
|
// Add Enter key support for search
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
document.getElementById('userSearchInput').addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
searchUsers();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Test message form handler
|
|
document.getElementById('testMessageForm').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const chatId = document.getElementById('testChatId').value;
|
|
const message = document.getElementById('testMessage').value;
|
|
|
|
if (!chatId || !message) {
|
|
showAlert('Please fill all fields', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/telegram/send`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
chat_id: parseInt(chatId),
|
|
text: message
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
showAlert('Message sent successfully', 'success');
|
|
document.getElementById('testMessage').value = '';
|
|
} else {
|
|
const error = await response.text();
|
|
showAlert('Error sending message: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showAlert('Error sending message: ' + error.message, 'error');
|
|
}
|
|
});
|
|
|
|
// User Requests Functions
|
|
async function loadUserRequests() {
|
|
await loadRequestStats();
|
|
await loadPendingRequests();
|
|
await loadAllRequests();
|
|
}
|
|
|
|
async function loadRequestStats() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/user-requests`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const requests = data.items || [];
|
|
|
|
const stats = {
|
|
pending: requests.filter(r => r.status === 'pending').length,
|
|
approved: requests.filter(r => r.status === 'approved').length,
|
|
declined: requests.filter(r => r.status === 'declined').length
|
|
};
|
|
|
|
document.getElementById('pendingRequests').textContent = stats.pending;
|
|
document.getElementById('approvedRequests').textContent = stats.approved;
|
|
document.getElementById('declinedRequests').textContent = stats.declined;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading request stats:', error);
|
|
}
|
|
}
|
|
|
|
async function loadPendingRequests() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/user-requests?status=pending`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
renderPendingRequests(data.items || []);
|
|
} else {
|
|
document.getElementById('pendingRequestsTable').innerHTML = '<p class="error-message">Failed to load pending requests</p>';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading pending requests:', error);
|
|
document.getElementById('pendingRequestsTable').innerHTML = '<p class="error-message">Error loading pending requests</p>';
|
|
}
|
|
}
|
|
|
|
async function loadAllRequests() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/user-requests`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
renderAllRequests(data.items || []);
|
|
} else {
|
|
document.getElementById('allRequestsTable').innerHTML = '<p class="error-message">Failed to load requests</p>';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading requests:', error);
|
|
document.getElementById('allRequestsTable').innerHTML = '<p class="error-message">Error loading requests</p>';
|
|
}
|
|
}
|
|
|
|
function renderPendingRequests(requests) {
|
|
const container = document.getElementById('pendingRequestsTable');
|
|
|
|
if (requests.length === 0) {
|
|
container.innerHTML = '<p class="text-muted">No pending requests</p>';
|
|
return;
|
|
}
|
|
|
|
const html = `
|
|
<div class="request-cards">
|
|
${requests.map(request => `
|
|
<div class="request-card">
|
|
<div class="request-header">
|
|
<h4>${escapeHtml(request.full_name)}</h4>
|
|
<span class="badge badge-warning">Pending</span>
|
|
</div>
|
|
<div class="request-info">
|
|
<p>📱 Telegram: ${request.telegram_username ? '@' + escapeHtml(request.telegram_username) : 'ID: ' + request.telegram_id}</p>
|
|
<p>📅 Requested: ${new Date(request.created_at).toLocaleString()}</p>
|
|
${request.request_message ? `<p>💬 Message: ${escapeHtml(request.request_message)}</p>` : ''}
|
|
</div>
|
|
<div class="request-actions">
|
|
<button class="button button-success" onclick="approveRequest('${request.id}')">
|
|
✅ Approve
|
|
</button>
|
|
<button class="button button-danger" onclick="declineRequest('${request.id}')">
|
|
❌ Decline
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
container.classList.remove('loading');
|
|
}
|
|
|
|
function renderAllRequests(requests) {
|
|
const container = document.getElementById('allRequestsTable');
|
|
|
|
if (requests.length === 0) {
|
|
container.innerHTML = '<p class="text-muted">No requests found</p>';
|
|
return;
|
|
}
|
|
|
|
const html = `
|
|
<div class="table-responsive">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Telegram</th>
|
|
<th>Status</th>
|
|
<th>Requested</th>
|
|
<th>Processed By</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${requests.map(request => `
|
|
<tr>
|
|
<td>${escapeHtml(request.full_name)}</td>
|
|
<td>${request.telegram_username ? '@' + escapeHtml(request.telegram_username) : 'ID: ' + request.telegram_id}</td>
|
|
<td>
|
|
<span class="badge badge-${getStatusBadgeClass(request.status)}">
|
|
${escapeHtml(request.status)}
|
|
</span>
|
|
</td>
|
|
<td>${new Date(request.created_at).toLocaleString()}</td>
|
|
<td>${request.processed_at ? new Date(request.processed_at).toLocaleString() : '-'}</td>
|
|
<td>
|
|
${request.status === 'pending' ? `
|
|
<button class="button button-small button-success" onclick="approveRequest('${request.id}')">
|
|
Approve
|
|
</button>
|
|
<button class="button button-small button-danger" onclick="declineRequest('${request.id}')">
|
|
Decline
|
|
</button>
|
|
` : `
|
|
<button class="button button-small button-outline" onclick="deleteRequest('${request.id}')">
|
|
Delete
|
|
</button>
|
|
`}
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
container.classList.remove('loading');
|
|
}
|
|
|
|
function getStatusBadgeClass(status) {
|
|
switch (status) {
|
|
case 'pending': return 'warning';
|
|
case 'approved': return 'success';
|
|
case 'declined': return 'danger';
|
|
default: return 'secondary';
|
|
}
|
|
}
|
|
|
|
async function approveRequest(requestId) {
|
|
const message = prompt('Optional message for the user:');
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/user-requests/${requestId}/approve`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ response_message: message })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showAlert('Request approved successfully', 'success');
|
|
await loadUserRequests();
|
|
} else {
|
|
const error = await response.text();
|
|
showAlert('Failed to approve request: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showAlert('Error approving request: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function declineRequest(requestId) {
|
|
const message = prompt('Reason for declining (optional):');
|
|
|
|
if (message === null) return; // User cancelled
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/user-requests/${requestId}/decline`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ response_message: message })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showAlert('Request declined', 'info');
|
|
await loadUserRequests();
|
|
} else {
|
|
const error = await response.text();
|
|
showAlert('Failed to decline request: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showAlert('Error declining request: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteRequest(requestId) {
|
|
if (!confirm('Are you sure you want to delete this request?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${API_BASE}/user-requests/${requestId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
showAlert('Request deleted', 'success');
|
|
await loadUserRequests();
|
|
} else {
|
|
showAlert('Failed to delete request', 'error');
|
|
}
|
|
} catch (error) {
|
|
showAlert('Error deleting request: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Update loadPageData function to include telegram
|
|
const originalLoadPageData = window.loadPageData;
|
|
window.loadPageData = function(page) {
|
|
if (page === 'telegram') {
|
|
loadTelegram();
|
|
} else if (page === 'user-requests') {
|
|
loadUserRequests();
|
|
} else if (originalLoadPageData) {
|
|
originalLoadPageData(page);
|
|
}
|
|
};
|
|
|
|
// Initialize
|
|
loadPageData('dashboard');
|
|
</script>
|
|
</body>
|
|
</html> |