refactor: extract all inline CSS/JS from PHP files into separate asset files #88
457
public/assets/css/landing.css
Normal file
457
public/assets/css/landing.css
Normal file
@@ -0,0 +1,457 @@
|
||||
body.auth-page {
|
||||
display: block;
|
||||
align-items: unset;
|
||||
justify-content: unset;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
body.auth-page .auth-container {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.landing-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.landing-hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: radial-gradient(ellipse at 50% 30%, rgba(99,102,241,0.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(34,197,94,0.05) 0%, transparent 50%),
|
||||
var(--bg-primary);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(1px 1px at 20% 30%, rgba(255,255,255,0.15) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 40% 70%, rgba(255,255,255,0.12) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 60% 20%, rgba(255,255,255,0.15) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 80% 60%, rgba(255,255,255,0.12) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 10% 80%, rgba(255,255,255,0.08) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 90% 10%, rgba(255,255,255,0.08) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-elements {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floating-elem {
|
||||
position: absolute;
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.08;
|
||||
animation: floatAround 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.floating-elem:nth-child(1) { top: 15%; left: 10%; animation-delay: 0s; font-size: 2rem; }
|
||||
.floating-elem:nth-child(2) { top: 25%; right: 12%; animation-delay: -3s; }
|
||||
.floating-elem:nth-child(3) { top: 60%; left: 8%; animation-delay: -7s; font-size: 1.8rem; }
|
||||
.floating-elem:nth-child(4) { top: 70%; right: 8%; animation-delay: -11s; }
|
||||
.floating-elem:nth-child(5) { top: 40%; left: 5%; animation-delay: -5s; font-size: 1.2rem; }
|
||||
.floating-elem:nth-child(6) { top: 50%; right: 5%; animation-delay: -9s; font-size: 1.6rem; }
|
||||
.floating-elem:nth-child(7) { top: 10%; left: 50%; animation-delay: -13s; font-size: 1rem; }
|
||||
.floating-elem:nth-child(8) { top: 85%; left: 50%; animation-delay: -15s; }
|
||||
|
||||
@keyframes floatAround {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-30px) rotate(5deg); }
|
||||
50% { transform: translateY(0) rotate(0deg); }
|
||||
75% { transform: translateY(20px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
.hero-icon-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
animation: heroIconEnter 1s ease-out;
|
||||
}
|
||||
|
||||
.hero-server-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(99,102,241,0.05));
|
||||
border: 1px solid rgba(99,102,241,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.4rem;
|
||||
color: var(--accent);
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-server-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
opacity: 0.15;
|
||||
z-index: -1;
|
||||
animation: glowPulse 3s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.15; transform: scale(1); }
|
||||
50% { opacity: 0.3; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes heroIconEnter {
|
||||
from { opacity: 0; transform: translateY(-30px) scale(0.8); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 0.75rem;
|
||||
animation: fadeSlideUp 0.8s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.hero-title .gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: clamp(1rem, 2vw, 1.2rem);
|
||||
color: var(--text-muted);
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
animation: fadeSlideUp 0.8s ease-out 0.35s both;
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
animation: fadeSlideUp 0.8s ease-out 0.5s both;
|
||||
}
|
||||
|
||||
.hero-cta .btn {
|
||||
padding: 0.85rem 2.2rem;
|
||||
font-size: 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-cta .btn-primary-custom {
|
||||
background: linear-gradient(135deg, var(--accent), #818cf8);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
|
||||
}
|
||||
|
||||
.hero-cta .btn-primary-custom:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 28px rgba(99,102,241,0.4);
|
||||
}
|
||||
|
||||
.hero-cta .btn-secondary-custom {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hero-cta .btn-secondary-custom:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero-terminal {
|
||||
margin-top: 3rem;
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
animation: fadeSlideUp 0.8s ease-out 0.65s both;
|
||||
}
|
||||
|
||||
.terminal-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.terminal-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.terminal-dot.red { background: #ff5f57; }
|
||||
.terminal-dot.yellow { background: #ffbd2e; }
|
||||
.terminal-dot.green { background: #28c840; }
|
||||
|
||||
.terminal-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.terminal-body-landing {
|
||||
padding: 1.25rem 1.25rem 1.5rem;
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.terminal-line-landing {
|
||||
opacity: 0;
|
||||
animation: terminalFadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.terminal-line-landing .prompt { color: var(--success); }
|
||||
.terminal-line-landing .cmd { color: #e1e4ed; }
|
||||
.terminal-line-landing .output-success { color: var(--success); }
|
||||
.terminal-line-landing .output-info { color: var(--info); }
|
||||
.terminal-line-landing .output-muted { color: var(--text-muted); }
|
||||
.terminal-line-landing .blinking-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
background: var(--accent);
|
||||
margin-left: 2px;
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes terminalFadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeSlideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
animation: fadeSlideUp 1s ease-out 1.5s both;
|
||||
}
|
||||
|
||||
.scroll-indicator .mouse {
|
||||
width: 24px;
|
||||
height: 38px;
|
||||
border: 2px solid var(--text-muted);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.scroll-indicator .mouse::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
animation: scrollWheel 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes scrollWheel {
|
||||
0% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
100% { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
}
|
||||
|
||||
.features-section {
|
||||
padding: 5rem 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.features-header h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.features-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.75rem;
|
||||
transition: all 0.4s ease-out;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.feature-card.visible:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.feature-icon.blue { background: rgba(59,130,246,0.15); color: var(--info); }
|
||||
.feature-icon.green { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.feature-icon.purple { background: rgba(139,92,246,0.15); color: var(--purple); }
|
||||
.feature-icon.orange { background: rgba(245,158,11,0.15); color: var(--warning); }
|
||||
.feature-icon.red { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||||
.feature-icon.teal { background: rgba(20,184,166,0.15); color: #14b8a6; }
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
padding: 4rem 2rem 5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 3rem;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: all 0.5s ease-out;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.landing-footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.landing-footer a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-footer a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title { font-size: 1.8rem; }
|
||||
.features-grid { grid-template-columns: 1fr; }
|
||||
.stats-grid { gap: 2rem; }
|
||||
.stats-grid .stat-item { min-width: 120px; }
|
||||
.scroll-indicator { display: none; }
|
||||
.landing-hero { padding-bottom: 5rem; }
|
||||
}
|
||||
19
public/assets/css/legal.css
Normal file
19
public/assets/css/legal.css
Normal file
@@ -0,0 +1,19 @@
|
||||
body.auth-page {
|
||||
display: block;
|
||||
align-items: unset;
|
||||
justify-content: unset;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body.auth-page .auth-container {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width:768px) {
|
||||
.legal-container .legal-card { width: 92% !important; padding: 1.5rem !important; }
|
||||
}
|
||||
@media (max-width:480px) {
|
||||
.legal-container .legal-card { width: 96% !important; padding: 1rem !important; }
|
||||
}
|
||||
40
public/assets/js/admin-notifications.js
Normal file
40
public/assets/js/admin-notifications.js
Normal file
@@ -0,0 +1,40 @@
|
||||
function showSendModal() {
|
||||
document.getElementById('notificationModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('notificationModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function sendNotification() {
|
||||
const form = document.getElementById('notificationForm');
|
||||
const formData = new FormData(form);
|
||||
const btn = document.querySelector('#notificationModal .btn-primary');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
|
||||
|
||||
fetch('/admin/notifications/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: new URLSearchParams(formData),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', data.message);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast('error', data.message);
|
||||
}
|
||||
closeModal();
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('error', 'Failed to send notification');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
||||
});
|
||||
}
|
||||
17
public/assets/js/admin-security.js
Normal file
17
public/assets/js/admin-security.js
Normal file
@@ -0,0 +1,17 @@
|
||||
function generateKey() {
|
||||
if (!confirm('WARNING: Generating a new master key will make all existing encrypted credentials UNREADABLE. ' +
|
||||
'You will need to re-enter all SSH credentials. Continue?')) return;
|
||||
|
||||
fetch('/admin/security/generate-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(document.querySelector('meta[name="csrf-token"]')?.content || ''),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
});
|
||||
}
|
||||
76
public/assets/js/admin-users.js
Normal file
76
public/assets/js/admin-users.js
Normal file
@@ -0,0 +1,76 @@
|
||||
function showCreateUserModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create User';
|
||||
document.getElementById('userId').value = '';
|
||||
document.getElementById('modalUsername').value = '';
|
||||
document.getElementById('modalEmail').value = '';
|
||||
document.getElementById('modalPassword').value = '';
|
||||
document.getElementById('modalPassword').required = true;
|
||||
document.getElementById('passwordHint').style.display = 'block';
|
||||
document.getElementById('modalRole').value = 'admin';
|
||||
document.getElementById('modalActive').checked = true;
|
||||
document.getElementById('saveUserBtn').textContent = 'Create';
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function editUser(id, username, email, role, isActive) {
|
||||
document.getElementById('modalTitle').textContent = 'Edit User';
|
||||
document.getElementById('userId').value = id;
|
||||
document.getElementById('modalUsername').value = username;
|
||||
document.getElementById('modalEmail').value = email;
|
||||
document.getElementById('modalPassword').value = '';
|
||||
document.getElementById('modalPassword').required = false;
|
||||
document.getElementById('passwordHint').style.display = 'none';
|
||||
document.getElementById('modalRole').value = role;
|
||||
document.getElementById('modalActive').checked = !!isActive;
|
||||
document.getElementById('saveUserBtn').textContent = 'Update';
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveUser() {
|
||||
const id = document.getElementById('userId').value;
|
||||
const formData = new FormData(document.getElementById('userForm'));
|
||||
|
||||
const isEdit = !!id;
|
||||
const url = isEdit ? '/admin/users/' + id + '/update' : '/admin/users/create';
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: new URLSearchParams(formData),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', data.message);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast('error', data.message);
|
||||
}
|
||||
closeModal();
|
||||
})
|
||||
.catch(err => showToast('error', 'Failed to save user'));
|
||||
}
|
||||
|
||||
function deleteUser(id, username) {
|
||||
if (!confirm('Are you sure you want to delete user "' + username + '"?')) return;
|
||||
|
||||
fetch('/admin/users/' + id + '/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(document.querySelector('meta[name="csrf-token"]')?.content || ''),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) location.reload();
|
||||
else showToast('error', data.message);
|
||||
});
|
||||
}
|
||||
0
public/assets/js/app.js
Executable file → Normal file
0
public/assets/js/app.js
Executable file → Normal file
40
public/assets/js/audit-log.js
Normal file
40
public/assets/js/audit-log.js
Normal file
@@ -0,0 +1,40 @@
|
||||
function buildQuery() {
|
||||
const params = [];
|
||||
const fields = [
|
||||
{ key: 'action', id: 'f_action' },
|
||||
{ key: 'username', id: 'f_username' },
|
||||
{ key: 'ip_address', id: 'f_ip_address' },
|
||||
{ key: 'entity_type', id: 'f_entity_type' },
|
||||
{ key: 'details', id: 'f_details' },
|
||||
{ key: 'date_from', id: 'f_date_from' },
|
||||
{ key: 'date_to', id: 'f_date_to' },
|
||||
];
|
||||
fields.forEach(function (f) {
|
||||
const el = document.getElementById(f.id);
|
||||
if (el && el.value) {
|
||||
params.push(encodeURIComponent(f.key) + '=' + encodeURIComponent(el.value));
|
||||
}
|
||||
});
|
||||
return params.length ? '/admin/audit?' + params.join('&') : '/admin/audit';
|
||||
}
|
||||
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target.matches('#f_date_from, #f_date_to, #f_action, #f_entity_type')) {
|
||||
window.location.href = buildQuery();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && e.target.matches('#f_username, #f_details, #f_ip_address')) {
|
||||
window.location.href = buildQuery();
|
||||
}
|
||||
});
|
||||
|
||||
function clearFilters() {
|
||||
window.location.href = '/admin/audit';
|
||||
}
|
||||
|
||||
function clearField(id) {
|
||||
document.getElementById(id).value = '';
|
||||
window.location.href = buildQuery();
|
||||
}
|
||||
136
public/assets/js/dashboard-chart.js
Normal file
136
public/assets/js/dashboard-chart.js
Normal file
@@ -0,0 +1,136 @@
|
||||
let aggregatedChart = null;
|
||||
|
||||
function initAggregatedChart(data) {
|
||||
const container = document.getElementById('aggregatedChart').parentNode;
|
||||
const oldCanvas = document.getElementById('aggregatedChart');
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.id = 'aggregatedChart';
|
||||
newCanvas.height = 300;
|
||||
container.replaceChild(newCanvas, oldCanvas);
|
||||
const ctx = newCanvas.getContext('2d');
|
||||
const labels = data.map(m => {
|
||||
const d = new Date(m.time_bucket);
|
||||
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
|
||||
});
|
||||
|
||||
aggregatedChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU',
|
||||
data: data.map(m => parseFloat(m.avg_cpu)),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
{
|
||||
label: 'RAM',
|
||||
data: data.map(m => parseFloat(m.avg_ram)),
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
{
|
||||
label: 'Disk',
|
||||
data: data.map(m => parseFloat(m.avg_disk)),
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#9ca3af',
|
||||
font: { family: "'Inter', sans-serif" },
|
||||
boxWidth: 12,
|
||||
padding: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1a1d2b',
|
||||
borderColor: '#2a2d3d',
|
||||
borderWidth: 1,
|
||||
titleColor: '#e1e4ed',
|
||||
bodyColor: '#9ca3af',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
maxTicksLimit: 12,
|
||||
font: { size: 11 },
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
font: { size: 11 },
|
||||
callback: function(v) { return v + '%'; },
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAggregatedChart() {
|
||||
fetch('/dashboard/chart-data')
|
||||
.then(r => r.json())
|
||||
.then(response => {
|
||||
if (response.success && response.data) {
|
||||
const data = response.data;
|
||||
const labels = data.map(m => {
|
||||
const d = new Date(m.time_bucket);
|
||||
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
|
||||
});
|
||||
if (aggregatedChart) {
|
||||
aggregatedChart.data.labels = labels;
|
||||
aggregatedChart.data.datasets[0].data = data.map(m => parseFloat(m.avg_cpu));
|
||||
aggregatedChart.data.datasets[1].data = data.map(m => parseFloat(m.avg_ram));
|
||||
aggregatedChart.data.datasets[2].data = data.map(m => parseFloat(m.avg_disk));
|
||||
aggregatedChart.update('none');
|
||||
} else if (data.length > 0) {
|
||||
initAggregatedChart(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (aggregatedData.length > 0) {
|
||||
initAggregatedChart(aggregatedData);
|
||||
}
|
||||
|
||||
setInterval(refreshAggregatedChart, 30000);
|
||||
316
public/assets/js/database-manager.js
Normal file
316
public/assets/js/database-manager.js
Normal file
@@ -0,0 +1,316 @@
|
||||
let currentDb = '';
|
||||
let currentTable = '';
|
||||
let openedDb = '';
|
||||
|
||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
||||
|
||||
function filterTables(inp) {
|
||||
const q = inp.value.toLowerCase();
|
||||
const body = inp.closest('.pma-accordion-body');
|
||||
if (body) {
|
||||
body.querySelectorAll('.pma-table-item').forEach(el => {
|
||||
el.style.display = !q || (el.dataset.tbl || '').includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function filterSidebar(q) {
|
||||
document.querySelectorAll('.pma-accordion-item').forEach(el => {
|
||||
el.style.display = el.textContent.toLowerCase().includes(q.toLowerCase()) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDb(db) {
|
||||
const item = document.querySelector(`.pma-accordion-item[data-db="${db.replace(/"/g,'\\"')}"]`);
|
||||
if (!item) return;
|
||||
const body = item.querySelector('.pma-accordion-body');
|
||||
const header = item.querySelector('.pma-accordion-header');
|
||||
const chevron = item.querySelector('.pma-chevron');
|
||||
|
||||
if (item.classList.contains('expanded')) {
|
||||
item.classList.remove('expanded');
|
||||
body.style.maxHeight = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.pma-accordion-item.expanded').forEach(el => {
|
||||
el.classList.remove('expanded');
|
||||
el.querySelector('.pma-accordion-body').style.maxHeight = '0';
|
||||
});
|
||||
|
||||
item.classList.add('expanded');
|
||||
currentDb = db;
|
||||
|
||||
if (body.querySelector('.pma-table-item')) {
|
||||
body.style.maxHeight = body.scrollHeight + 'px';
|
||||
return;
|
||||
}
|
||||
|
||||
body.querySelector('.pma-loading-tables').style.display = '';
|
||||
body.style.maxHeight = '60px';
|
||||
|
||||
fetch('/servers/' + serverId + '/database', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body: 'action=tables&db=' + encodeURIComponent(db) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
body.querySelector('.pma-loading-tables').style.display = 'none';
|
||||
let html = '<div style="padding:0.3rem 0.5rem"><input type="text" class="form-input tbl-filter" placeholder="Filter tables..." style="font-size:0.75rem;padding:0.25rem 0.4rem;width:100%;box-sizing:border-box" oninput="filterTables(this)"></div>';
|
||||
(data.tables || []).forEach(t => {
|
||||
html += '<div class="pma-table-item" data-tbl="' + escHtml(t.name).toLowerCase() + '" onclick="selectTable(\'' + db.replace(/'/g,"\\'") + "','" + t.name.replace(/'/g,"\\'") + '\')">'
|
||||
+ '<i class="fas fa-table"></i> ' + escHtml(t.name)
|
||||
+ ' <span class="pma-db-size">' + t.rows + ' rows</span></div>';
|
||||
});
|
||||
if (!html) html = '<div class="pma-table-item" style="color:var(--text-muted);cursor:default">No tables</div>';
|
||||
body.innerHTML = html;
|
||||
body.style.maxHeight = body.scrollHeight + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
function selectTable(db, table) {
|
||||
currentDb = db; currentTable = table;
|
||||
document.getElementById('tabBrowse').style.display = '';
|
||||
document.getElementById('tabStructure').style.display = '';
|
||||
document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById('tabStructure').classList.add('active');
|
||||
showPanel('structure');
|
||||
loadStructure(db, table);
|
||||
}
|
||||
|
||||
function loadStructure(db, table) {
|
||||
const panel = document.getElementById('pmaStructure');
|
||||
panel.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading structure...</div>';
|
||||
fetch('/servers/' + serverId + '/database', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action=structure&db='+encodeURIComponent(db)+'&table='+encodeURIComponent(table)+'&_csrf_token='+encodeURIComponent(getCsrfToken()),
|
||||
}).then(r=>r.json()).then(d=>{
|
||||
if (!d.success||!d.columns) return;
|
||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-list"></i> <code>'+escHtml(table)+'</code></h2>'
|
||||
+'<span class="badge badge-info">'+d.columns.length+' columns</span>'
|
||||
+'<span style="flex:1"></span>'
|
||||
+'<button class="btn btn-xs btn-info" onclick="browseTable(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\')"><i class="fas fa-search"></i> Browse</button>'
|
||||
+'</div>';
|
||||
html+='<div class="table-responsive"><table class="table table-sm"><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Collation</th><th>Null</th><th>Key</th><th>Default</th><th>Extra</th></tr></thead><tbody>';
|
||||
d.columns.forEach((c,i)=>{
|
||||
html+='<tr><td>'+(i+1)+'</td><td><strong>'+escHtml(c.field)+'</strong></td><td><code>'+escHtml(c.type)+'</code></td>'
|
||||
+'<td>'+escHtml(c.collation)+'</td><td>'+escHtml(c.null)+'</td><td><strong>'+escHtml(c.key)+'</strong></td>'
|
||||
+'<td>'+(c.default??'NULL')+'</td><td>'+escHtml(c.extra)+'</td></tr>';
|
||||
});
|
||||
html+='</tbody></table></div>';
|
||||
panel.innerHTML=html;
|
||||
});
|
||||
}
|
||||
|
||||
function browseTable(db, table) {
|
||||
currentDb=db; currentTable=table;
|
||||
document.getElementById('tabBrowse').style.display='';
|
||||
document.getElementById('tabStructure').style.display='';
|
||||
document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById('tabBrowse').classList.add('active');
|
||||
showPanel('browse');
|
||||
loadBrowse(db,table,1);
|
||||
}
|
||||
|
||||
function loadBrowse(db,table,page) {
|
||||
const panel=document.getElementById('pmaBrowse');
|
||||
|
||||
const inputs=document.querySelectorAll('.pma-fi');
|
||||
let filters='';
|
||||
if(inputs.length){
|
||||
const p={};
|
||||
inputs.forEach(inp=>{if(inp.value.trim())p[inp.dataset.col]=inp.value.trim()});
|
||||
const s=new URLSearchParams(p).toString();
|
||||
if(s)filters='&'+s;
|
||||
}
|
||||
|
||||
panel.innerHTML='<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||||
|
||||
let body='action=browse&db='+encodeURIComponent(db)+'&table='+encodeURIComponent(table)+'&page='+page+filters+'&_csrf_token='+encodeURIComponent(getCsrfToken());
|
||||
|
||||
fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(!d.success)return;
|
||||
const tp=Math.ceil(d.total/d.per_page);
|
||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:0.5rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-search"></i> <code>'+escHtml(table)+'</code></h2>'
|
||||
+'<span class="badge badge-info">'+d.total+' rows</span></div>';
|
||||
html+='<form onsubmit="event.preventDefault();loadBrowse(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\',1)">';
|
||||
html+='<div class="table-responsive" style="max-height:450px;overflow-y:auto"><table class="table table-sm"><thead><tr>';
|
||||
d.columns.forEach(c=>{html+='<th>'+escHtml(c)+'</th>'});
|
||||
html+='</tr><tr class="pma-filter-row">';
|
||||
d.columns.forEach(c=>{
|
||||
const cv=new URLSearchParams(filters.replace(/^&/,'')).get(c)||'';
|
||||
html+='<td><input type="text" class="form-input pma-fi" data-col="'+escHtml(c)+'" value="'+escHtml(cv)+'" placeholder="'+escHtml(c)+'" style="font-size:0.75rem;padding:0.2rem 0.35rem;width:100%;min-width:50px"></td>';
|
||||
});
|
||||
html+='</tr></thead><tbody>';
|
||||
d.rows.forEach(row=>{
|
||||
html+='<tr>';
|
||||
row.forEach(val=>{html+='<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis"><code>'+escHtml(String(val).substring(0,80))+'</code></td>'});
|
||||
html+='</tr>';
|
||||
});
|
||||
html+='</tbody></table></div>';
|
||||
html+='<div style="display:flex;gap:0.5rem;margin-top:0.5rem;align-items:center">';
|
||||
html+='<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-search"></i> Filter</button>';
|
||||
html+='<button type="button" class="btn btn-secondary btn-sm" onclick="clearBrowseFilters(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\')"><i class="fas fa-undo"></i> Clear</button>';
|
||||
if(tp>1){
|
||||
html+='<span style="flex:1"></span>';
|
||||
for(let p=1;p<=tp&&p<=10;p++)html+='<button type="button" class="btn btn-xs '+(p===page?'btn-primary':'btn-secondary')+'" onclick="loadBrowse(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\','+p+')">'+p+'</button>';
|
||||
if(tp>10)html+='<span class="text-muted">...</span>';
|
||||
}
|
||||
html+='</div></form>';
|
||||
panel.innerHTML=html;
|
||||
});
|
||||
}
|
||||
|
||||
function clearBrowseFilters(db,table){
|
||||
document.querySelectorAll('.pma-fi').forEach(inp=>inp.value='');
|
||||
loadBrowse(db,table,1);
|
||||
}
|
||||
|
||||
function loadUsers() {
|
||||
showPanel('users');
|
||||
document.getElementById('tabUsers').style.display='';
|
||||
const panel=document.getElementById('pmaUsers');
|
||||
panel.innerHTML='<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading users...</div>';
|
||||
|
||||
fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=users&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-users"></i> Database Users</h2>'
|
||||
+'<span class="badge badge-info">'+(d.users?.length||0)+' users</span>'
|
||||
+'<span style="flex:1"></span>'
|
||||
+'<button class="btn btn-sm btn-success" onclick="showCreateUser()"><i class="fas fa-plus"></i> New User</button></div>';
|
||||
html+='<div class="table-responsive"><table class="table table-sm"><thead><tr><th>User</th><th>Host</th><th>Databases</th><th>Expired</th><th>Actions</th></tr></thead><tbody>';
|
||||
(d.users||[]).forEach(u=>{
|
||||
const dbList=(d.grants||[]).filter(g=>g.user===u.user&&g.host===u.host).map(g=>g.db).join(', ');
|
||||
html+='<tr><td><strong>'+escHtml(u.user)+'</strong></td><td><code>'+escHtml(u.host)+'</code></td><td>'+(dbList||'<span class="text-muted">-</span>')+'</td><td>'+escHtml(u.expired)+'</td>'
|
||||
+'<td class="actions-cell">'
|
||||
+'<button class="btn btn-xs btn-secondary" onclick="showEditUser(\''+escHtml(u.user)+"','"+escHtml(u.host)+'\')" title="Change password"><i class="fas fa-key"></i></button> '
|
||||
+'<button class="btn btn-xs btn-danger" onclick="deleteUser(\''+escHtml(u.user)+"','"+escHtml(u.host)+'\')" title="Delete"><i class="fas fa-trash"></i></button>'
|
||||
+'</td></tr>';
|
||||
});
|
||||
html+='</tbody></table></div>';
|
||||
panel.innerHTML=html;
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateUser() {
|
||||
const panel=document.getElementById('pmaUsers');
|
||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-user-plus"></i> Create User</h2>'
|
||||
+'<span style="flex:1"></span>'
|
||||
+'<button class="btn btn-sm btn-secondary" onclick="loadUsers()"><i class="fas fa-arrow-left"></i> Back</button></div>'
|
||||
+'<div class="dashboard-grid"><div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-user"></i> Account</h2></div><div class="card-body">'
|
||||
+'<div class="form-row"><div class="form-group"><label>Username</label><input type="text" id="nuUser" class="form-input" placeholder="username"></div>'
|
||||
+'<div class="form-group"><label>Password</label><input type="password" id="nuPass" class="form-input" placeholder="password"></div>'
|
||||
+'<div class="form-group"><label>Host</label><input type="text" id="nuHost" class="form-input" value="localhost" placeholder="localhost"></div></div></div></div>'
|
||||
+'<div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-database"></i> Database Access</h2></div><div class="card-body">'
|
||||
+'<div style="margin-bottom:0.75rem"><label class="checkbox-label"><input type="checkbox" id="nuAllDb" onchange="document.querySelectorAll(\'.nuDbCheck\').forEach(c=>c.checked=this.checked)"> <strong>Select all</strong></label></div>';
|
||||
|
||||
databases.forEach(function(dbname) {
|
||||
if (dbname !== 'information_schema' && dbname !== 'performance_schema' && dbname !== 'mysql' && dbname !== 'sys') {
|
||||
html+='<label class="checkbox-label" style="margin-top:0.25rem"><input type="checkbox" class="nuDbCheck" value="'+dbname+'"> <span>'+dbname+'</span></label>';
|
||||
}
|
||||
});
|
||||
|
||||
html+='</div></div></div>'
|
||||
+'<div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-lock"></i> Privileges</h2></div><div class="card-body">'
|
||||
+'<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.25rem">';
|
||||
['ALL PRIVILEGES','SELECT','INSERT','UPDATE','DELETE','CREATE','DROP','ALTER','INDEX','CREATE VIEW','SHOW VIEW','CREATE ROUTINE','ALTER ROUTINE','EXECUTE','LOCK TABLES','REFERENCES','EVENT','TRIGGER'].forEach(function(p){
|
||||
var checked = p === 'ALL PRIVILEGES' ? ' checked' : '';
|
||||
html+='<label class="checkbox-label" style="font-size:0.82rem"><input type="checkbox" class="nuPriv" value="'+p+'"'+checked+'> <span>'+p+'</span></label>';
|
||||
});
|
||||
html+='</div></div></div>'
|
||||
+'<div style="margin-top:1rem;display:flex;gap:0.5rem">'
|
||||
+'<button class="btn btn-primary" onclick="doCreateUser()"><i class="fas fa-save"></i> Create User</button>'
|
||||
+'<button class="btn btn-secondary" onclick="loadUsers()">Cancel</button></div>';
|
||||
panel.innerHTML=html;
|
||||
}
|
||||
|
||||
function doCreateUser() {
|
||||
const user=document.getElementById('nuUser').value;
|
||||
const pass=document.getElementById('nuPass').value;
|
||||
const host=document.getElementById('nuHost').value||'localhost';
|
||||
if(!user||!pass){showToast('error','Username and password required');return;}
|
||||
|
||||
const privs=[];
|
||||
document.querySelectorAll('.nuPriv:checked').forEach(cb=>privs.push(cb.value));
|
||||
const grantPrivs = privs.includes('ALL PRIVILEGES') ? 'ALL PRIVILEGES' : privs.join(',');
|
||||
|
||||
fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body: 'action=create_user&new_user='+encodeURIComponent(user)+'&new_pass='+encodeURIComponent(pass)+'&new_host='+encodeURIComponent(host)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
showToast(d.success?'success':'error',d.message);
|
||||
if(d.success){
|
||||
const dbs=[];
|
||||
document.querySelectorAll('.nuDbCheck:checked').forEach(cb=>dbs.push(cb.value));
|
||||
if(dbs.length>0){
|
||||
let cnt=0;
|
||||
dbs.forEach(db=>{
|
||||
fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=grant&grant_user='+encodeURIComponent(user)+'&grant_db='+encodeURIComponent(db)+'&grant_host='+encodeURIComponent(host)+'&grant_privs='+encodeURIComponent(grantPrivs)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r2=>r2.json()).then(g=>{
|
||||
cnt++;
|
||||
if(cnt===dbs.length)loadUsers();
|
||||
});
|
||||
});
|
||||
}else{loadUsers();}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showEditUser(user, host) {
|
||||
const panel = document.getElementById('pmaUsers');
|
||||
panel.innerHTML='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-key"></i> Change Password</h2>'
|
||||
+'<code>'+escHtml(user)+'</code> @ <code>'+escHtml(host)+'</code>'
|
||||
+'<span style="flex:1"></span>'
|
||||
+'<button class="btn btn-sm btn-secondary" onclick="loadUsers()"><i class="fas fa-arrow-left"></i> Back</button></div>'
|
||||
+'<div class="card"><div class="card-body">'
|
||||
+'<div class="form-group"><label>New Password</label><input type="password" id="epPass" class="form-input" placeholder="new password"></div>'
|
||||
+'<div style="margin-top:1rem;display:flex;gap:0.5rem">'
|
||||
+'<button class="btn btn-primary" onclick="doChangePass(\''+escHtml(user)+"','"+escHtml(host)+'\')"><i class="fas fa-save"></i> Save</button>'
|
||||
+'<button class="btn btn-secondary" onclick="loadUsers()">Cancel</button></div></div></div>';
|
||||
}
|
||||
|
||||
function doChangePass(user, host) {
|
||||
const pass = document.getElementById('epPass').value;
|
||||
if (!pass) { showToast('error','Password required'); return; }
|
||||
fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action=change_pass&target_user='+encodeURIComponent(user)+'&target_host='+encodeURIComponent(host)+'&new_pass='+encodeURIComponent(pass)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)loadUsers()});
|
||||
}
|
||||
|
||||
function deleteUser(user, host) {
|
||||
if (!confirm('Delete user \''+user+'\'@\''+host+'\'?\nThis cannot be undone.')) return;
|
||||
fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action=drop_user&target_user='+encodeURIComponent(user)+'&target_host='+encodeURIComponent(host)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)loadUsers()});
|
||||
}
|
||||
|
||||
function runSql() {
|
||||
const sql=document.getElementById('sqlInput').value; if(!sql)return;
|
||||
const rd=document.getElementById('sqlResult'); const rt=document.getElementById('sqlResultText');
|
||||
rd.style.display='none';
|
||||
fetch('/servers/' + serverId + '/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=query&query='+encodeURIComponent(sql)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{if(d.output){rt.textContent=d.output;rd.style.display='block'}else showToast('error',d.message||'No results')});
|
||||
}
|
||||
|
||||
function showPanel(id) {
|
||||
document.querySelectorAll('.pma-panel').forEach(p=>p.classList.remove('active'));
|
||||
document.getElementById('pma'+id.charAt(0).toUpperCase()+id.slice(1)).classList.add('active');
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
|
||||
const map={browse:'pmaBrowse',structure:'pmaStructure',sql:'pmaSql',users:'pmaUsers'};
|
||||
const el=document.getElementById('tab'+tab.charAt(0).toUpperCase()+tab.slice(1));
|
||||
if(el)el.classList.add('active');
|
||||
showPanel(tab);
|
||||
if(tab==='sql'){document.getElementById('pmaWelcome').classList.remove('active');document.getElementById('pmaSql').classList.add('active');}
|
||||
}
|
||||
|
||||
document.getElementById('sqlInput').addEventListener('keydown',function(e){if((e.ctrlKey||e.metaKey)&&e.key==='Enter')runSql()});
|
||||
|
||||
function escHtml(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
||||
202
public/assets/js/main.js
Normal file
202
public/assets/js/main.js
Normal file
@@ -0,0 +1,202 @@
|
||||
let serverListLoaded = false;
|
||||
|
||||
function getApiToken() {
|
||||
return document.querySelector('meta[name="api-token"]')?.getAttribute('content') || '';
|
||||
}
|
||||
|
||||
function toggleNotifications() {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
if (dd.classList.contains('open')) {
|
||||
closeNotifications();
|
||||
} else {
|
||||
openNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function openNotifications() {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
dd.classList.add('open');
|
||||
fetchNotifications();
|
||||
document.addEventListener('click', notifOutsideClick);
|
||||
}
|
||||
|
||||
function closeNotifications() {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
dd.classList.remove('open');
|
||||
document.removeEventListener('click', notifOutsideClick);
|
||||
}
|
||||
|
||||
function notifOutsideClick(e) {
|
||||
const wrapper = document.querySelector('.notification-btn-wrapper');
|
||||
if (!wrapper.contains(e.target)) {
|
||||
closeNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function fetchNotifications() {
|
||||
const list = document.getElementById('notifList');
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)"><i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i><span>Loading notifications...</span></div>';
|
||||
|
||||
const token = getApiToken();
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications?per_page=10', { headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (!d.success) {
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">Could not load notifications.</div>';
|
||||
return;
|
||||
}
|
||||
renderNotifications(d.data || [], d.unread_count || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--danger)">Failed to load notifications.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(items, unreadCount) {
|
||||
const list = document.getElementById('notifList');
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">'
|
||||
+ '<i class="fas fa-check-circle" style="font-size:1.8em;margin-bottom:10px;display:block;color:var(--success)"></i>'
|
||||
+ '<span>No notifications</span></div>';
|
||||
document.getElementById('markAllReadBtn').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
items.forEach(function(n) {
|
||||
const typeColors = { error: 'danger', warning: 'warning', info: 'info' };
|
||||
const typeColor = typeColors[n.type] || 'info';
|
||||
const icons = { error: 'fa-times-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' };
|
||||
const icon = icons[n.type] || 'fa-info-circle';
|
||||
|
||||
html += '<div class="notif-item ' + (n.is_read ? 'notif-read' : 'notif-unread') + '"'
|
||||
+ ' onclick="markRead(' + n.id + ', this)"'
|
||||
+ ' data-id="' + n.id + '">'
|
||||
+ '<div class="notif-icon notif-' + typeColor + '"><i class="fas ' + icon + '"></i></div>'
|
||||
+ '<div class="notif-content">'
|
||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||
+ '<div class="notif-message">' + escapeHtml(n.message) + '</div>'
|
||||
+ '<div class="notif-time">' + (n.time_ago || '') + '</div>'
|
||||
+ '</div>'
|
||||
+ (n.is_read ? '' : '<div class="notif-dot"></div>')
|
||||
+ '</div>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
|
||||
document.getElementById('markAllReadBtn').style.display = unreadCount > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
function markRead(id, el) {
|
||||
if (el.classList.contains('notif-read')) return;
|
||||
|
||||
const token = getApiToken();
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications/' + id + '/read', { method: 'POST', headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success) {
|
||||
el.classList.remove('notif-unread');
|
||||
el.classList.add('notif-read');
|
||||
const dot = el.querySelector('.notif-dot');
|
||||
if (dot) dot.remove();
|
||||
updateUnreadCount();
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
const token = getApiToken();
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications/read-all', { method: 'POST', headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success) {
|
||||
document.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.classList.remove('notif-unread');
|
||||
el.classList.add('notif-read');
|
||||
const dot = el.querySelector('.notif-dot');
|
||||
if (dot) dot.remove();
|
||||
});
|
||||
document.getElementById('markAllReadBtn').style.display = 'none';
|
||||
updateUnreadCount();
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
function updateUnreadCount() {
|
||||
const token = getApiToken();
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications/unread-count', { headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const count = d.unread_count || 0;
|
||||
const badge = document.getElementById('notificationBadge');
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.style.display = 'inline-flex';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
setInterval(updateUnreadCount, 60000);
|
||||
updateUnreadCount();
|
||||
|
||||
function toggleServerList(e) {
|
||||
e.preventDefault();
|
||||
const item = e.currentTarget.closest('.nav-item-accordion');
|
||||
const sub = document.getElementById('serverList');
|
||||
const chevron = item.querySelector('.nav-chevron');
|
||||
if (item.classList.contains('expanded')) {
|
||||
item.classList.remove('expanded');
|
||||
sub.style.maxHeight = '0';
|
||||
return;
|
||||
}
|
||||
item.classList.add('expanded');
|
||||
if (!serverListLoaded) {
|
||||
serverListLoaded = true;
|
||||
fetch('/sidebar/servers')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success && d.data) {
|
||||
let html = '<a href="/servers" class="nav-sub-item"><i class="fas fa-list"></i> All Servers</a>';
|
||||
d.data.forEach(s => {
|
||||
const status = s.current_status === 'online' ? 'success' : s.current_status === 'offline' ? 'danger' : 'muted';
|
||||
html += '<a href="/servers/' + s.id + '" class="nav-sub-item">'
|
||||
+ '<span class="status-dot-sm status-' + status + '"></span> '
|
||||
+ escapeHtml(s.name) + '</a>';
|
||||
});
|
||||
sub.innerHTML = html;
|
||||
} else {
|
||||
sub.innerHTML = '<div class="nav-sub-item" style="color:var(--text-muted)">No servers</div>';
|
||||
}
|
||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||
})
|
||||
.catch(() => {
|
||||
sub.innerHTML = '<div class="nav-sub-item" style="color:var(--danger)">Failed to load</div>';
|
||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||
});
|
||||
} else {
|
||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
321
public/assets/js/nginx-manager.js
Normal file
321
public/assets/js/nginx-manager.js
Normal file
@@ -0,0 +1,321 @@
|
||||
let currentEditFile = null;
|
||||
let editorDirty = false;
|
||||
let editorUndoStack = [];
|
||||
let editorRedoStack = [];
|
||||
let editorHistoryIndex = -1;
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function updateLineNumbers() {
|
||||
const ta = document.getElementById('editorContent');
|
||||
const gutter = document.getElementById('editorGutter');
|
||||
const lines = ta.value.split('\n').length;
|
||||
const numbers = [];
|
||||
for (let i = 1; i <= lines; i++) {
|
||||
numbers.push('<span>' + i + '</span>');
|
||||
}
|
||||
gutter.innerHTML = numbers.join('');
|
||||
}
|
||||
|
||||
function updateCursorPos() {
|
||||
const ta = document.getElementById('editorContent');
|
||||
const before = ta.value.substring(0, ta.selectionStart);
|
||||
const lines = before.split('\n');
|
||||
const line = lines.length;
|
||||
const col = lines[lines.length - 1].length + 1;
|
||||
document.getElementById('editorCursorPos').textContent = 'Ln ' + line + ', Col ' + col;
|
||||
}
|
||||
|
||||
function highlightNginx(text) {
|
||||
let h = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/#(.*)$/gm, '<span class="hl-comment">#$1</span>')
|
||||
.replace(/\b(server|location|listen|server_name|root|index|try_files|include|fastcgi_pass|proxy_pass|return|rewrite|set|if|error_page|ssl_certificate|ssl_certificate_key|ssl_protocols|ssl_ciphers|add_header|access_log|error_log|keepalive|gzip|proxy_set_header|proxy_redirect|client_max_body_size|autoindex)\b/g, '<span class="hl-keyword">$1</span>')
|
||||
.replace(/\b(\d+)\b/g, '<span class="hl-number">$1</span>')
|
||||
.replace(/\b(https?|fastcgi|unix):\/\/[^\s;{]+/g, '<span class="hl-string">$&</span>')
|
||||
.replace(/'(.*?)'/g, '<span class="hl-string">\'$1\'</span>')
|
||||
.replace(/(\$\w+)/g, '<span class="hl-var">$1</span>')
|
||||
.replace(/\b(\w+\.\w+(?:\.\w+)*)\b/g, function(m) {
|
||||
if (m.includes('com') || m.includes('org') || m.includes('net') || m.includes('io') || m.includes('local')) {
|
||||
return '<span class="hl-string">' + m + '</span>';
|
||||
}
|
||||
return m;
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
function syncHighlight() {
|
||||
const ta = document.getElementById('editorContent');
|
||||
const code = document.getElementById('editorHighlightCode');
|
||||
code.innerHTML = highlightNginx(ta.value) + '\n';
|
||||
}
|
||||
|
||||
function pushHistory() {
|
||||
const ta = document.getElementById('editorContent');
|
||||
if (editorHistoryIndex >= 0 && editorUndoStack[editorHistoryIndex] === ta.value) return;
|
||||
editorUndoStack = editorUndoStack.slice(0, editorHistoryIndex + 1);
|
||||
editorUndoStack.push(ta.value);
|
||||
editorRedoStack = [];
|
||||
editorHistoryIndex = editorUndoStack.length - 1;
|
||||
if (editorUndoStack.length > 100) {
|
||||
editorUndoStack.shift();
|
||||
editorHistoryIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
function undoEditor() {
|
||||
if (editorHistoryIndex <= 0) return;
|
||||
editorRedoStack.push(editorUndoStack[editorHistoryIndex]);
|
||||
editorHistoryIndex--;
|
||||
const ta = document.getElementById('editorContent');
|
||||
ta.value = editorUndoStack[editorHistoryIndex];
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
function redoEditor() {
|
||||
if (editorRedoStack.length === 0) return;
|
||||
const val = editorRedoStack.pop();
|
||||
editorUndoStack.push(val);
|
||||
editorHistoryIndex++;
|
||||
const ta = document.getElementById('editorContent');
|
||||
ta.value = val;
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
function nginxAction(action) {
|
||||
const btn = event.target.closest('button');
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Working...';
|
||||
|
||||
fetch('/servers/' + serverId + '/nginx', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'action=' + action + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.output) {
|
||||
document.getElementById('nginxResultMessage').textContent = data.message;
|
||||
document.getElementById('nginxResultOutputText').textContent = data.output;
|
||||
document.getElementById('nginxResultOutput').style.display = 'block';
|
||||
document.getElementById('nginxResultModal').style.display = 'flex';
|
||||
}
|
||||
if (data.success && ['restart', 'reload', 'start', 'stop'].includes(action)) {
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message))
|
||||
.finally(() => { btn.disabled = false; btn.innerHTML = origText; });
|
||||
}
|
||||
|
||||
function editFile(file) {
|
||||
currentEditFile = file;
|
||||
editorUndoStack = [];
|
||||
editorRedoStack = [];
|
||||
editorHistoryIndex = -1;
|
||||
editorDirty = false;
|
||||
document.getElementById('editorFilePath').textContent = file;
|
||||
document.getElementById('editorContent').value = 'Loading...';
|
||||
document.getElementById('editorStatus').textContent = 'Loading...';
|
||||
document.getElementById('editorDirtyIndicator').style.display = 'none';
|
||||
document.getElementById('editorFileSize').textContent = '';
|
||||
document.getElementById('nginxEditorModal').style.display = 'flex';
|
||||
setTimeout(() => document.getElementById('editorContent').focus(), 100);
|
||||
|
||||
fetch('/servers/' + serverId + '/nginx/file?file=' + encodeURIComponent(file))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const ta = document.getElementById('editorContent');
|
||||
ta.value = data.content;
|
||||
document.getElementById('editorFileSize').textContent = (data.content.length / 1024).toFixed(1) + ' KB';
|
||||
document.getElementById('editorStatus').textContent = 'Ready';
|
||||
document.getElementById('editorDirtyIndicator').style.display = 'none';
|
||||
editorDirty = false;
|
||||
updateLineNumbers();
|
||||
syncHighlight();
|
||||
pushHistory();
|
||||
} else {
|
||||
showToast('error', data.message);
|
||||
closeEditor();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('error', 'Failed to load file');
|
||||
closeEditor();
|
||||
});
|
||||
|
||||
const ta = document.getElementById('editorContent');
|
||||
ta.oninput = function() {
|
||||
editorDirty = true;
|
||||
document.getElementById('editorStatus').textContent = 'Unsaved changes';
|
||||
document.getElementById('editorDirtyIndicator').style.display = 'inline';
|
||||
updateLineNumbers();
|
||||
syncHighlight();
|
||||
pushHistory();
|
||||
};
|
||||
ta.onscroll = function() {
|
||||
document.getElementById('editorGutter').scrollTop = this.scrollTop;
|
||||
document.getElementById('editorHighlight').scrollTop = this.scrollTop;
|
||||
};
|
||||
ta.onkeydown = function(e) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + 4;
|
||||
this.dispatchEvent(new Event('input'));
|
||||
}
|
||||
};
|
||||
ta.onmouseup = ta.onkeyup = function() {
|
||||
updateCursorPos();
|
||||
};
|
||||
ta.addEventListener('paste', function() {
|
||||
setTimeout(() => {
|
||||
this.dispatchEvent(new Event('input'));
|
||||
updateCursorPos();
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function saveFile() {
|
||||
const content = document.getElementById('editorContent').value;
|
||||
document.getElementById('editorStatus').textContent = 'Saving...';
|
||||
|
||||
fetch('/servers/' + serverId + '/nginx/file', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'file=' + encodeURIComponent(currentEditFile) + '&content=' + encodeURIComponent(content) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) {
|
||||
editorDirty = false;
|
||||
document.getElementById('editorStatus').textContent = 'Saved';
|
||||
document.getElementById('editorDirtyIndicator').style.display = 'none';
|
||||
closeEditor();
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
document.getElementById('editorStatus').textContent = 'Save failed';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('error', 'Save request failed');
|
||||
document.getElementById('editorStatus').textContent = 'Error';
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSite(site) {
|
||||
if (!confirm("Permanently delete site '" + site + "'?\nThis will remove both the config file and site directory contents.")) return;
|
||||
if (!confirm("Are you sure? This cannot be undone.")) return;
|
||||
|
||||
fetch('/servers/' + serverId + '/nginx/site', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_method=DELETE&site=' + encodeURIComponent(site) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) setTimeout(() => location.reload(), 500);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function toggleSite(site, enable) {
|
||||
const action = enable ? 'enable' : 'disable';
|
||||
if (!confirm((enable ? 'Enable' : 'Disable') + " site '" + site + "'?")) return;
|
||||
|
||||
fetch('/servers/' + serverId + '/nginx/toggle-site', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'site=' + encodeURIComponent(site) + '&enable=' + (enable ? '1' : '0') + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) setTimeout(() => location.reload(), 500);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (editorDirty && !confirm('Discard unsaved changes?')) return;
|
||||
document.getElementById('nginxEditorModal').style.display = 'none';
|
||||
currentEditFile = null;
|
||||
editorDirty = false;
|
||||
}
|
||||
|
||||
function closeNginxModal() {
|
||||
document.getElementById('nginxResultModal').style.display = 'none';
|
||||
document.getElementById('nginxResultOutput').style.display = 'none';
|
||||
}
|
||||
|
||||
function openNewSiteModal() {
|
||||
document.getElementById('newSiteForm').reset();
|
||||
document.getElementById('siteServerName').value = '';
|
||||
document.getElementById('sitePhp').checked = true;
|
||||
document.getElementById('siteEnable').checked = true;
|
||||
document.getElementById('nginxNewSiteModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeNewSiteModal() {
|
||||
document.getElementById('nginxNewSiteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function createSite(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('createSiteBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
|
||||
|
||||
const formData = new URLSearchParams(new FormData(document.getElementById('newSiteForm')));
|
||||
formData.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/servers/' + serverId + '/nginx/site', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: formData.toString(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) {
|
||||
closeNewSiteModal();
|
||||
setTimeout(() => location.reload(), 800);
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message))
|
||||
.finally(() => { btn.disabled = false; btn.innerHTML = '<i class="fas fa-plus"></i> Create Site'; });
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') { closeEditor(); closeNginxModal(); }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && currentEditFile) {
|
||||
e.preventDefault();
|
||||
saveFile();
|
||||
}
|
||||
});
|
||||
20
public/assets/js/notifications.js
Normal file
20
public/assets/js/notifications.js
Normal file
@@ -0,0 +1,20 @@
|
||||
function markAllRead() {
|
||||
const token = document.querySelector('meta[name="api-token"]')?.getAttribute('content') || '';
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications/read-all', { method: 'POST', headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success) {
|
||||
document.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.classList.remove('notif-unread');
|
||||
el.classList.add('notif-read');
|
||||
const dot = el.querySelector('.notif-dot');
|
||||
if (dot) dot.remove();
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
30
public/assets/js/server-list.js
Normal file
30
public/assets/js/server-list.js
Normal file
@@ -0,0 +1,30 @@
|
||||
function deleteServer(id, name) {
|
||||
if (confirm('Are you sure you want to delete server "' + name + '"?\nThis action cannot be undone.')) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/servers/' + id + '/delete';
|
||||
const csrf = document.createElement('input');
|
||||
csrf.type = 'hidden';
|
||||
csrf.name = '_csrf_token';
|
||||
csrf.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
form.appendChild(csrf);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('serverSearch')?.addEventListener('input', function(e) {
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
document.getElementById('statusFilter')?.addEventListener('change', applyFilters);
|
||||
document.getElementById('groupFilter')?.addEventListener('change', applyFilters);
|
||||
|
||||
function applyFilters() {
|
||||
const search = document.getElementById('serverSearch')?.value || '';
|
||||
const status = document.getElementById('statusFilter')?.value || '';
|
||||
const group = document.getElementById('groupFilter')?.value || '';
|
||||
window.location.href = '/servers?search=' + encodeURIComponent(search) +
|
||||
'&status=' + encodeURIComponent(status) +
|
||||
'&group=' + encodeURIComponent(group);
|
||||
}
|
||||
40
public/assets/js/server-permissions.js
Normal file
40
public/assets/js/server-permissions.js
Normal file
@@ -0,0 +1,40 @@
|
||||
function createPermissionRow(type, value, description) {
|
||||
const idx = permIndex++;
|
||||
const container = document.getElementById('permissions-container');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'permission-row';
|
||||
div.dataset.index = idx;
|
||||
div.innerHTML = `
|
||||
<select name="permissions[${idx}][type]" class="form-input permission-type">
|
||||
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
|
||||
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
|
||||
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
|
||||
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
|
||||
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
|
||||
</select>
|
||||
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
|
||||
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
|
||||
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
|
||||
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
function removePermissionRow(btn) {
|
||||
btn.closest('.permission-row').remove();
|
||||
}
|
||||
|
||||
function addPresetPermission(type, value, description) {
|
||||
createPermissionRow(type, value, description);
|
||||
}
|
||||
|
||||
document.getElementById('addPermissionBtn').addEventListener('click', function() {
|
||||
createPermissionRow('command', '', '');
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
62
public/assets/js/server-processes.js
Normal file
62
public/assets/js/server-processes.js
Normal file
@@ -0,0 +1,62 @@
|
||||
let pendingPid = null;
|
||||
let pendingBtn = null;
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function killProcess(pid, btn) {
|
||||
pendingPid = pid;
|
||||
pendingBtn = btn;
|
||||
document.getElementById('killPidDisplay').textContent = pid;
|
||||
document.getElementById('killModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeKillModal() {
|
||||
pendingPid = null;
|
||||
pendingBtn = null;
|
||||
document.getElementById('killForce').checked = false;
|
||||
document.getElementById('killModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function confirmKill() {
|
||||
if (!pendingPid) return;
|
||||
|
||||
const signal = document.getElementById('killForce').checked ? 9 : 15;
|
||||
const btn = document.getElementById('confirmKillBtn');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Killing...';
|
||||
|
||||
fetch('/servers/' + serverId + '/processes/kill', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'pid=' + pendingPid + '&signal=' + signal + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success && pendingBtn) {
|
||||
const row = pendingBtn.closest('tr');
|
||||
row.style.opacity = '0.4';
|
||||
pendingBtn.disabled = true;
|
||||
pendingBtn.innerHTML = '<i class="fas fa-check"></i> Killed';
|
||||
}
|
||||
closeKillModal();
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('error', 'Request failed: ' + err.message);
|
||||
closeKillModal();
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeKillModal();
|
||||
});
|
||||
86
public/assets/js/server-services.js
Normal file
86
public/assets/js/server-services.js
Normal file
@@ -0,0 +1,86 @@
|
||||
function applyFilters() {
|
||||
const nameQ = document.getElementById('filterName').value.toLowerCase();
|
||||
const statusQ = document.getElementById('filterStatus').value;
|
||||
const filtered = allServices.filter(s => {
|
||||
if (nameQ && !s.name.toLowerCase().includes(nameQ)) return false;
|
||||
if (statusQ && s.status !== statusQ) return false;
|
||||
return true;
|
||||
});
|
||||
renderPage(filtered, 1);
|
||||
}
|
||||
|
||||
function renderPage(list, page) {
|
||||
const totalPages = Math.ceil(list.length / perPage) || 1;
|
||||
const start = (page - 1) * perPage;
|
||||
const slice = list.slice(start, start + perPage);
|
||||
const tbody = document.getElementById('servicesBody');
|
||||
let html = '';
|
||||
slice.forEach(s => {
|
||||
const isActive = s.status === 'active';
|
||||
const isInactive = s.status === 'inactive';
|
||||
html += '<tr class="service-row">';
|
||||
html += '<td><code>' + escHtml(s.name) + '</code></td>';
|
||||
html += '<td><span class="badge ' + (isActive ? 'badge-success' : isInactive ? 'badge-secondary' : 'badge-warning') + '">' + s.status + '</span></td>';
|
||||
html += '<td class="actions-cell" style="white-space:nowrap">';
|
||||
if (!isActive) html += '<button class="btn btn-xs btn-success" onclick="serviceAction(\'start\',\'' + escHtml(s.name) + '\')" title="Start"><i class="fas fa-play"></i></button> ';
|
||||
if (!isInactive) html += '<button class="btn btn-xs btn-danger" onclick="serviceAction(\'stop\',\'' + escHtml(s.name) + '\')" title="Stop"><i class="fas fa-stop"></i></button> ';
|
||||
html += '<button class="btn btn-xs btn-warning" onclick="serviceAction(\'restart\',\'' + escHtml(s.name) + '\')" title="Restart"><i class="fas fa-redo"></i></button> ';
|
||||
html += '<button class="btn btn-xs btn-info" onclick="serviceAction(\'reload\',\'' + escHtml(s.name) + '\')" title="Reload"><i class="fas fa-sync-alt"></i></button> ';
|
||||
if (!s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'enable\',\'' + escHtml(s.name) + '\')" title="Enable"><i class="fas fa-power-off"></i></button> ';
|
||||
if (s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'disable\',\'' + escHtml(s.name) + '\')" title="Disable"><i class="fas fa-ban"></i></button> ';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
if (!html) html = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:2rem">No services match the filter.</td></tr>';
|
||||
tbody.innerHTML = html;
|
||||
renderPagination(list, page);
|
||||
}
|
||||
|
||||
function renderPagination(list, page) {
|
||||
const totalPages = Math.ceil(list.length / perPage) || 1;
|
||||
const container = document.getElementById('servicesPagination');
|
||||
let html = '';
|
||||
for (let p = 1; p <= totalPages && p <= 15; p++) {
|
||||
html += '<button class="btn btn-xs ' + (p === page ? 'btn-primary' : 'btn-secondary') + '" onclick="renderPage(list,' + p + ')" data-list=\'' + JSON.stringify(list).replace(/'/g,"'") + '\'>' + p + '</button>';
|
||||
}
|
||||
if (totalPages > 15) html += '<span class="text-muted" style="font-size:0.8rem">...</span>';
|
||||
container.innerHTML = html + ' <span class="text-muted" style="font-size:0.8rem">' + list.length + ' services</span>';
|
||||
container.querySelectorAll('button').forEach(btn => {
|
||||
const p = parseInt(btn.textContent);
|
||||
if (!isNaN(p)) {
|
||||
btn.onclick = function() { renderPage(list, p); };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
|
||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
||||
|
||||
function serviceAction(action, service) {
|
||||
fetch('/servers/' + serverId + '/services', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action='+action+'&service='+encodeURIComponent(service)+'&_csrf_token='+encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
showToast(d.success?'success':'error', d.message);
|
||||
if (d.output) {
|
||||
document.getElementById('srTitle').textContent = d.success ? 'Success' : 'Error';
|
||||
document.getElementById('srMessage').textContent = d.message;
|
||||
document.getElementById('srOutputText').textContent = d.output;
|
||||
document.getElementById('srOutput').style.display = 'block';
|
||||
document.getElementById('serviceResultModal').style.display = 'flex';
|
||||
}
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed'));
|
||||
}
|
||||
|
||||
function closeSrModal() {
|
||||
document.getElementById('serviceResultModal').style.display = 'none';
|
||||
document.getElementById('srOutput').style.display = 'none';
|
||||
}
|
||||
|
||||
function escHtml(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
|
||||
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeSrModal(); });
|
||||
323
public/assets/js/server-show.js
Normal file
323
public/assets/js/server-show.js
Normal file
@@ -0,0 +1,323 @@
|
||||
let metricsChart = null;
|
||||
|
||||
function initChart(data) {
|
||||
const container = document.getElementById('metricsChart').parentNode;
|
||||
const oldCanvas = document.getElementById('metricsChart');
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.id = 'metricsChart';
|
||||
newCanvas.height = 300;
|
||||
container.replaceChild(newCanvas, oldCanvas);
|
||||
const ctx = newCanvas.getContext('2d');
|
||||
const labels = data.map(m => {
|
||||
const d = new Date(m.created_at);
|
||||
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
|
||||
});
|
||||
|
||||
metricsChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU',
|
||||
data: data.map(m => m.cpu_usage),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
{
|
||||
label: 'RAM',
|
||||
data: data.map(m => m.ram_usage),
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
{
|
||||
label: 'Disk',
|
||||
data: data.map(m => m.disk_usage),
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#9ca3af',
|
||||
font: { family: "'Inter', sans-serif" },
|
||||
boxWidth: 12,
|
||||
padding: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1a1d2b',
|
||||
borderColor: '#2a2d3d',
|
||||
borderWidth: 1,
|
||||
titleColor: '#e1e4ed',
|
||||
bodyColor: '#9ca3af',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
maxTicksLimit: 12,
|
||||
font: { size: 11 },
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
font: { size: 11 },
|
||||
callback: function(v) { return v + '%'; },
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (historicalData.length > 0) {
|
||||
initChart(historicalData);
|
||||
}
|
||||
|
||||
let pendingAgentAction = null;
|
||||
|
||||
function showAgentModal(action) {
|
||||
pendingAgentAction = action;
|
||||
const modal = document.getElementById('agentConfirmModal');
|
||||
const title = document.getElementById('agentModalTitle');
|
||||
const msg = document.getElementById('agentModalMessage');
|
||||
const btn = document.getElementById('agentModalConfirmBtn');
|
||||
|
||||
if (action === 'install') {
|
||||
title.textContent = 'Install Monitoring Agent';
|
||||
msg.innerHTML = 'This will install the monitoring agent on <strong>serverName</strong> via SSH.';
|
||||
btn.textContent = 'Install';
|
||||
btn.className = 'btn btn-primary';
|
||||
} else {
|
||||
title.textContent = 'Uninstall Monitoring Agent';
|
||||
msg.innerHTML = 'This will uninstall the monitoring agent from <strong>serverName</strong> via SSH. The service will be stopped and all agent files removed.';
|
||||
btn.textContent = 'Uninstall';
|
||||
btn.className = 'btn btn-danger';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
modal.onclick = function(e) {
|
||||
if (e.target === modal) closeAgentModal();
|
||||
};
|
||||
}
|
||||
|
||||
function closeAgentModal() {
|
||||
document.getElementById('agentConfirmModal').style.display = 'none';
|
||||
pendingAgentAction = null;
|
||||
}
|
||||
|
||||
function confirmAgentAction() {
|
||||
if (!pendingAgentAction) return;
|
||||
|
||||
const action = pendingAgentAction;
|
||||
const btn = document.getElementById('agentModalConfirmBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = action === 'install' ? 'Installing...' : 'Uninstalling...';
|
||||
|
||||
closeAgentModal();
|
||||
|
||||
fetch('/servers/serverId/agent/' + action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', action === 'install' ? 'Agent installed successfully.' : 'Agent uninstalled successfully.');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
let msg = data.error || (action === 'install' ? 'Installation failed.' : 'Uninstallation failed.');
|
||||
if (data.output) msg += ' Output: ' + data.output.replace(/\n/g, ' | ');
|
||||
showToast('error', msg);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('error', 'Request failed: ' + err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = action === 'install' ? 'Install' : 'Uninstall';
|
||||
});
|
||||
}
|
||||
|
||||
function copyAgentKey() {
|
||||
const key = document.getElementById('agentKeyFull').value;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
showToast('success', 'Agent key copied to clipboard.');
|
||||
}).catch(() => {
|
||||
showToast('error', 'Failed to copy agent key.');
|
||||
});
|
||||
}
|
||||
|
||||
function regenerateAgentKey() {
|
||||
if (!confirm('Regenerate agent key? The current key will stop working immediately.')) return;
|
||||
|
||||
fetch('/servers/serverId/agent/regenerate-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', 'Agent key regenerated.');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast('error', data.error || 'Failed to regenerate key.');
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function serverAction(action) {
|
||||
if (action === 'shutdown' || action === 'reboot') {
|
||||
if (!confirm('Are you sure you want to ' + action + ' this server?')) return;
|
||||
}
|
||||
|
||||
fetch('/servers/serverId/' + action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function restartService() {
|
||||
const service = prompt('Enter service name to restart (e.g., nginx, mysql, apache2):');
|
||||
if (!service) return;
|
||||
|
||||
fetch('/servers/serverId/restart-service', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'service=' + encodeURIComponent(service) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function refreshMetrics() {
|
||||
fetch('/servers/serverId/metrics')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.data) {
|
||||
const m = data.data;
|
||||
document.getElementById('cpuMetric').textContent = (m.cpu_usage || '-') + '%';
|
||||
document.getElementById('ramMetric').textContent = (m.ram_usage || '-') + '%';
|
||||
document.getElementById('diskMetric').textContent = (m.disk_usage || '-') + '%';
|
||||
document.getElementById('loadMetric').textContent = m.load_average || '-';
|
||||
document.getElementById('uptimeMetric').textContent = m.uptime || '-';
|
||||
if (metricsChart && metricsChart.data.labels.length > 0) {
|
||||
const now = new Date();
|
||||
const label = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
|
||||
metricsChart.data.labels.push(label);
|
||||
metricsChart.data.datasets[0].data.push(m.cpu_usage);
|
||||
metricsChart.data.datasets[1].data.push(m.ram_usage);
|
||||
metricsChart.data.datasets[2].data.push(m.disk_usage);
|
||||
if (metricsChart.data.labels.length > 48) {
|
||||
metricsChart.data.labels.shift();
|
||||
metricsChart.data.datasets.forEach(ds => ds.data.shift());
|
||||
}
|
||||
metricsChart.update('none');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(refreshMetrics, 30000);
|
||||
|
||||
function showThresholdModal() {
|
||||
document.getElementById('thresholdModal').style.display = 'flex';
|
||||
document.getElementById('thresholdModal').onclick = function(e) {
|
||||
if (e.target === this) closeThresholdModal();
|
||||
};
|
||||
}
|
||||
|
||||
function closeThresholdModal() {
|
||||
document.getElementById('thresholdModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveThresholds() {
|
||||
const form = document.getElementById('thresholdForm');
|
||||
const data = new URLSearchParams(new FormData(form));
|
||||
data.append('_csrf_token', getCsrfToken());
|
||||
|
||||
document.querySelector('#thresholdModal .btn-primary').disabled = true;
|
||||
|
||||
fetch('/servers/serverId/thresholds', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: data.toString(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
showToast(d.success ? 'success' : 'error', d.message ?? d.error);
|
||||
if (d.success) closeThresholdModal();
|
||||
})
|
||||
.catch(e => showToast('error', 'Request failed: ' + e.message))
|
||||
.finally(() => {
|
||||
document.querySelector('#thresholdModal .btn-primary').disabled = false;
|
||||
});
|
||||
}
|
||||
73
public/assets/js/server-ssl.js
Normal file
73
public/assets/js/server-ssl.js
Normal file
@@ -0,0 +1,73 @@
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function sslAction(action, extraData) {
|
||||
const data = 'action=' + action + '&_csrf_token=' + encodeURIComponent(getCsrfToken());
|
||||
const body = extraData ? data + '&' + extraData : data;
|
||||
|
||||
fetch('/servers/' + serverId + '/ssl', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: body,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.output) {
|
||||
document.getElementById('sslResultMessage').textContent = data.message || '';
|
||||
document.getElementById('sslResultOutputText').textContent = data.output;
|
||||
document.getElementById('sslResultOutput').style.display = 'block';
|
||||
document.getElementById('sslResultModal').style.display = 'flex';
|
||||
}
|
||||
if (action === 'install' && data.success) {
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
}
|
||||
if (['renew', 'delete'].includes(action) && data.success) {
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function openRequestModal() {
|
||||
document.getElementById('sslRequestForm').reset();
|
||||
document.getElementById('sslRequestModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeRequestModal() {
|
||||
document.getElementById('sslRequestModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function requestCert(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('requestCertBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Requesting...';
|
||||
|
||||
const domains = document.getElementById('sslDomains').value;
|
||||
const email = document.getElementById('sslEmail').value;
|
||||
|
||||
sslAction('request', 'domains=' + encodeURIComponent(domains) + '&email=' + encodeURIComponent(email));
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-plus"></i> Request Certificate';
|
||||
closeRequestModal();
|
||||
}
|
||||
|
||||
function deleteCert(name) {
|
||||
if (!confirm("Delete certificate '" + name + "'?")) return;
|
||||
sslAction('delete', 'cert_name=' + encodeURIComponent(name));
|
||||
}
|
||||
|
||||
function closeSslResultModal() {
|
||||
document.getElementById('sslResultModal').style.display = 'none';
|
||||
document.getElementById('sslResultOutput').style.display = 'none';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') { closeRequestModal(); closeSslResultModal(); }
|
||||
});
|
||||
42
public/assets/js/system-users.js
Normal file
42
public/assets/js/system-users.js
Normal file
@@ -0,0 +1,42 @@
|
||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
||||
|
||||
function showAddUser() { document.getElementById('addUserModal').style.display = 'flex'; }
|
||||
function showAddGroup() { document.getElementById('addGroupModal').style.display = 'flex'; }
|
||||
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
|
||||
|
||||
function doAddUser() {
|
||||
const data = 'action=add&username=' + encodeURIComponent(document.getElementById('suUser').value)
|
||||
+ '&password=' + encodeURIComponent(document.getElementById('suPass').value)
|
||||
+ '&groups=' + encodeURIComponent(document.getElementById('suGroups').value)
|
||||
+ '&create_home=' + (document.getElementById('suHome').checked ? '1' : '0')
|
||||
+ '&_csrf_token=' + encodeURIComponent(getCsrfToken());
|
||||
fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:data})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
closeModal('addUserModal');
|
||||
}
|
||||
|
||||
function doAddGroup() {
|
||||
const data = 'action=addgroup&group=' + encodeURIComponent(document.getElementById('sgGroup').value) + '&_csrf_token=' + encodeURIComponent(getCsrfToken());
|
||||
fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:data})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
closeModal('addGroupModal');
|
||||
}
|
||||
|
||||
function lockUser(u) {
|
||||
if(!confirm('Lock user \''+u+'\'?'))return;
|
||||
fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=lock&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
function unlockUser(u) {
|
||||
fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=unlock&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
function deleteUser(u) {
|
||||
if(!confirm('Delete user \''+u+'\'?\nThis cannot be undone.'))return;
|
||||
fetch('/servers/' + serverId + '/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=delete&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeModal('addUserModal');closeModal('addGroupModal')}});
|
||||
59
public/assets/js/team-index.js
Normal file
59
public/assets/js/team-index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const allCards = document.querySelectorAll('.team-card');
|
||||
|
||||
function filterTeams(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
const grid = document.getElementById('teamsGrid');
|
||||
const empty = document.getElementById('emptyState');
|
||||
const clearBtn = document.getElementById('searchClear');
|
||||
let visible = 0;
|
||||
|
||||
clearBtn.style.display = q ? 'block' : 'none';
|
||||
|
||||
if (grid) {
|
||||
allCards.forEach(function(card) {
|
||||
const name = card.dataset.name || '';
|
||||
const desc = card.dataset.desc || '';
|
||||
const match = !q || name.includes(q) || desc.includes(q);
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) visible++;
|
||||
});
|
||||
|
||||
if (visible === 0) {
|
||||
if (!empty) {
|
||||
const container = document.getElementById('teamsContainer');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'empty-state';
|
||||
div.id = 'emptyState';
|
||||
div.innerHTML = '<i class="fas fa-users-cog"></i><p>No teams match your search.</p>';
|
||||
container.appendChild(div);
|
||||
} else {
|
||||
empty.querySelector('p').textContent = 'No teams match your search.';
|
||||
empty.style.display = '';
|
||||
}
|
||||
} else {
|
||||
if (empty) empty.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const url = q ? '?search=' + encodeURIComponent(query) : window.location.pathname;
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
document.getElementById('teamSearch')?.addEventListener('input', function() {
|
||||
filterTeams(this.value);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('teamSearch');
|
||||
if (searchInput && searchInput.value) {
|
||||
filterTeams(searchInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
function deleteTeam(id, name) {
|
||||
if (confirm('Delete team "' + name + '"?\nThis will remove access for all members.')) {
|
||||
const form = document.getElementById('deleteForm');
|
||||
form.action = '/teams/' + id + '/delete';
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
187
public/assets/js/team-show.js
Normal file
187
public/assets/js/team-show.js
Normal file
@@ -0,0 +1,187 @@
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function editTeam() {
|
||||
const name = prompt('Team name:', teamName);
|
||||
if (!name) return;
|
||||
const desc = prompt('Description:', teamDescription);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('name', name);
|
||||
form.append('description', desc || '');
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/' + teamId + '/update', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function deleteTeam() {
|
||||
if (!confirm('Delete this team? Members will lose access to assigned servers.')) return;
|
||||
document.getElementById('postForm').action = '/teams/' + teamId + '/delete';
|
||||
document.getElementById('postForm').submit();
|
||||
}
|
||||
|
||||
let searchTimeout = null;
|
||||
let selectedUserId = null;
|
||||
|
||||
document.getElementById('addUserInput').addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const q = this.value.trim();
|
||||
selectedUserId = null;
|
||||
|
||||
if (q.length < 2) {
|
||||
document.getElementById('userDropdown').classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(function() {
|
||||
fetch('/teams/search-users?q=' + encodeURIComponent(q), {
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const dropdown = document.getElementById('userDropdown');
|
||||
dropdown.innerHTML = '';
|
||||
if (data.users && data.users.length > 0) {
|
||||
data.users.forEach(function(u) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'autocomplete-item';
|
||||
item.innerHTML = '<strong>' + escapeHtml(u.username) + '</strong> <small>' + escapeHtml(u.email) + '</small>';
|
||||
item.dataset.id = u.id;
|
||||
item.dataset.username = u.username;
|
||||
item.addEventListener('click', function() {
|
||||
selectUser(u.id, u.username);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
dropdown.classList.add('active');
|
||||
} else {
|
||||
dropdown.innerHTML = '<div class="autocomplete-empty">No users found</div>';
|
||||
dropdown.classList.add('active');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('userDropdown').classList.remove('active');
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.autocomplete')) {
|
||||
document.getElementById('userDropdown').classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
function selectUser(id, username) {
|
||||
selectedUserId = id;
|
||||
document.getElementById('addUserInput').value = username;
|
||||
document.getElementById('userDropdown').classList.remove('active');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('user_id', id);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/' + teamId + '/add-user', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
showToast('success', username + ' added to team');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast('error', res.message);
|
||||
document.getElementById('addUserInput').value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeMember(userId, username) {
|
||||
if (!confirm('Remove "' + username + '" from this team?')) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('user_id', userId);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/' + teamId + '/remove-user', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function addServer(e) {
|
||||
e.preventDefault();
|
||||
const serverId = document.getElementById('addServerId').value;
|
||||
const role = document.getElementById('addServerRole').value;
|
||||
if (!serverId) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('server_id', serverId);
|
||||
form.append('role', role);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/' + teamId + '/add-server', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function removeServer(serverId, name) {
|
||||
if (!confirm('Remove "' + name + '" from this team?')) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('server_id', serverId);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/' + teamId + '/remove-server', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function updateServerRole(serverId, role) {
|
||||
const form = new FormData();
|
||||
form.append('server_id', serverId);
|
||||
form.append('role', role);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/' + teamId + '/update-server-role', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', 'Role updated to ' + role); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
110
public/assets/js/terminal.js
Normal file
110
public/assets/js/terminal.js
Normal file
@@ -0,0 +1,110 @@
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
const terminalInput = document.getElementById('terminalInput');
|
||||
const terminalOutput = document.getElementById('terminalOutput');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
|
||||
terminalInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
executeCommand();
|
||||
}
|
||||
});
|
||||
|
||||
function executeCommand() {
|
||||
const command = terminalInput.value.trim();
|
||||
if (!command) return;
|
||||
|
||||
appendTerminalLine(command, 'command');
|
||||
|
||||
terminalInput.value = '';
|
||||
terminalInput.disabled = true;
|
||||
document.getElementById('executeBtn').disabled = true;
|
||||
|
||||
updateStatus('executing', 'Executing...');
|
||||
|
||||
fetch('/terminal/' + serverId + '/execute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'command=' + encodeURIComponent(command) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
terminalInput.disabled = false;
|
||||
document.getElementById('executeBtn').disabled = false;
|
||||
terminalInput.focus();
|
||||
|
||||
if (data.success) {
|
||||
if (data.output) {
|
||||
appendTerminalLine(data.output, 'output');
|
||||
}
|
||||
if (data.stderr) {
|
||||
appendTerminalLine(data.stderr, 'error');
|
||||
}
|
||||
updateStatus('connected', 'Connected');
|
||||
} else {
|
||||
appendTerminalLine(data.message || 'Command failed', 'error');
|
||||
updateStatus('error', 'Error');
|
||||
}
|
||||
|
||||
const terminalBody = document.getElementById('terminalOutput');
|
||||
terminalBody.scrollTop = terminalBody.scrollHeight;
|
||||
})
|
||||
.catch(err => {
|
||||
terminalInput.disabled = false;
|
||||
document.getElementById('executeBtn').disabled = false;
|
||||
appendTerminalLine('Connection error: ' + err.message, 'error');
|
||||
updateStatus('error', 'Error');
|
||||
});
|
||||
}
|
||||
|
||||
function appendTerminalLine(text, type) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'terminal-line terminal-' + type;
|
||||
if (type === 'command') {
|
||||
line.innerHTML = '<span class="terminal-prompt-inline">$</span> ' + escapeHtml(text);
|
||||
} else {
|
||||
line.innerHTML = '<pre>' + escapeHtml(text) + '</pre>';
|
||||
}
|
||||
terminalOutput.appendChild(line);
|
||||
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
||||
}
|
||||
|
||||
function runQuickCmd(cmd) {
|
||||
terminalInput.value = cmd;
|
||||
executeCommand();
|
||||
}
|
||||
|
||||
function clearTerminal() {
|
||||
terminalOutput.innerHTML = '';
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
if (!confirm('Clear all command history for this server?')) return;
|
||||
|
||||
fetch('/terminal/' + serverId + '/clear-history', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function updateStatus(status, text) {
|
||||
const dot = connectionStatus.querySelector('.status-dot');
|
||||
dot.className = 'status-dot ' + status;
|
||||
connectionStatus.lastChild.textContent = ' ' + text;
|
||||
}
|
||||
|
||||
terminalInput.focus();
|
||||
@@ -23,12 +23,9 @@ try {
|
||||
$app->run();
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
if (($_ENV['APP_DEBUG'] ?? false) === 'true') {
|
||||
echo '<h1>Server Error</h1>';
|
||||
echo '<pre>' . htmlspecialchars($e->getMessage()) . '</pre>';
|
||||
echo '<pre>' . $e->getTraceAsString() . '</pre>';
|
||||
} else {
|
||||
if (isset($app)) {
|
||||
$app->getView()->error(500);
|
||||
}
|
||||
echo '<h1>500 Internal Server Error</h1>';
|
||||
echo '<p>An unexpected error occurred. Please check the logs.</p>';
|
||||
}
|
||||
echo '<p>An unexpected error occurred.</p>';
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class AuthMiddleware
|
||||
if (!headers_sent()) {
|
||||
header("Location: {$url}");
|
||||
} else {
|
||||
echo '<script>window.location.href="' . $url . '";</script>';
|
||||
echo '<meta http-equiv="refresh" content="0;url=' . htmlspecialchars($url) . '">';
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -75,210 +75,12 @@ class RateLimitMiddleware
|
||||
http_response_code(429);
|
||||
header("Retry-After: {$retryAfter}");
|
||||
|
||||
$retryMinutes = (int) ceil($retryAfter / 60);
|
||||
$retryAfterMs = $retryAfter * 1000;
|
||||
$requestUri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>429 Too Many Requests — ServerManager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg-primary: #0a0c10;
|
||||
--bg-secondary: #111318;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--text-primary: #e1e4ed;
|
||||
--text-muted: #7a7f8e;
|
||||
--border-color: #1e2030;
|
||||
--warning: #f59e0b;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rate-limit-card {
|
||||
text-align: center;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.icon-shield {
|
||||
width: 80px; height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: radial-gradient(circle, rgba(245,158,11,0.12) 0%, transparent 70%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 2.4rem;
|
||||
color: var(--warning);
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.error-code {
|
||||
font-size: 5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--warning), #f97316);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.error-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.error-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.countdown-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.countdown-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.countdown-timer {
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--warning);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.countdown-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
margin-top: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.countdown-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--warning), #f97316);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 2rem;
|
||||
background: linear-gradient(135deg, var(--accent), #818cf8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-primary.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.btn-primary.active:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
|
||||
}
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="rate-limit-card">
|
||||
<div class="icon-shield"><i class="fas fa-shield-halved"></i></div>
|
||||
<div class="error-code">429</div>
|
||||
<div class="error-title">Too Many Requests</div>
|
||||
<div class="error-desc">You have made too many requests in a short period. Please wait before trying again.</div>
|
||||
<div class="countdown-box">
|
||||
<div class="countdown-label">Retry in</div>
|
||||
<div class="countdown-timer" id="countdown">{$retryMinutes}:00</div>
|
||||
<div class="countdown-bar"><div class="countdown-bar-fill" id="progressBar"></div></div>
|
||||
</div>
|
||||
<a href="{$requestUri}" class="btn-primary" id="retryBtn"><i class="fas fa-rotate-right"></i> Try Again</a>
|
||||
<br>
|
||||
<a href="/dashboard" class="btn-secondary"><i class="fas fa-gauge-high"></i> Go to Dashboard</a>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var total = {$retryAfterMs};
|
||||
var remaining = total;
|
||||
var el = document.getElementById('countdown');
|
||||
var bar = document.getElementById('progressBar');
|
||||
var btn = document.getElementById('retryBtn');
|
||||
var start = Date.now();
|
||||
|
||||
function tick() {
|
||||
var elapsed = Date.now() - start;
|
||||
remaining = Math.max(0, total - elapsed);
|
||||
var secs = Math.ceil(remaining / 1000);
|
||||
var m = Math.floor(secs / 60);
|
||||
var s = secs % 60;
|
||||
el.textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
||||
bar.style.width = (remaining / total * 100) + '%';
|
||||
|
||||
if (remaining <= 0) {
|
||||
el.textContent = '0:00';
|
||||
bar.style.width = '0%';
|
||||
btn.classList.add('active');
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
echo App::getInstance()->getView()->renderView('errors.429', [
|
||||
'retryAfter' => $retryAfter,
|
||||
'retryMinutes' => (int) ceil($retryAfter / 60),
|
||||
'retryAfterMs' => $retryAfter * 1000,
|
||||
'requestUri' => htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8'),
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -187,45 +187,4 @@ function selected(string $value, string $compare): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function buildQuery() {
|
||||
const params = [];
|
||||
const fields = [
|
||||
{ key: 'action', id: 'f_action' },
|
||||
{ key: 'username', id: 'f_username' },
|
||||
{ key: 'ip_address', id: 'f_ip_address' },
|
||||
{ key: 'entity_type', id: 'f_entity_type' },
|
||||
{ key: 'details', id: 'f_details' },
|
||||
{ key: 'date_from', id: 'f_date_from' },
|
||||
{ key: 'date_to', id: 'f_date_to' },
|
||||
];
|
||||
fields.forEach(function (f) {
|
||||
const el = document.getElementById(f.id);
|
||||
if (el && el.value) {
|
||||
params.push(encodeURIComponent(f.key) + '=' + encodeURIComponent(el.value));
|
||||
}
|
||||
});
|
||||
return params.length ? '/admin/audit?' + params.join('&') : '/admin/audit';
|
||||
}
|
||||
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target.matches('#f_date_from, #f_date_to, #f_action, #f_entity_type')) {
|
||||
window.location.href = buildQuery();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && e.target.matches('#f_username, #f_details, #f_ip_address')) {
|
||||
window.location.href = buildQuery();
|
||||
}
|
||||
});
|
||||
|
||||
function clearFilters() {
|
||||
window.location.href = '/admin/audit';
|
||||
}
|
||||
|
||||
function clearField(id) {
|
||||
document.getElementById(id).value = '';
|
||||
window.location.href = buildQuery();
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/audit-log.js"></script>
|
||||
|
||||
@@ -139,45 +139,4 @@ $data = $notifications['data'] ?? [];
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showSendModal() {
|
||||
document.getElementById('notificationModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('notificationModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function sendNotification() {
|
||||
const form = document.getElementById('notificationForm');
|
||||
const formData = new FormData(form);
|
||||
const btn = document.querySelector('#notificationModal .btn-primary');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
|
||||
|
||||
fetch('/admin/notifications/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: new URLSearchParams(formData),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', data.message);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast('error', data.message);
|
||||
}
|
||||
closeModal();
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('error', 'Failed to send notification');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/admin-notifications.js"></script>
|
||||
|
||||
@@ -102,22 +102,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function generateKey() {
|
||||
if (!confirm('WARNING: Generating a new master key will make all existing encrypted credentials UNREADABLE. ' +
|
||||
'You will need to re-enter all SSH credentials. Continue?')) return;
|
||||
|
||||
fetch('/admin/security/generate-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(document.querySelector('meta[name="csrf-token"]')?.content || ''),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/admin-security.js"></script>
|
||||
|
||||
@@ -121,81 +121,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showCreateUserModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create User';
|
||||
document.getElementById('userId').value = '';
|
||||
document.getElementById('modalUsername').value = '';
|
||||
document.getElementById('modalEmail').value = '';
|
||||
document.getElementById('modalPassword').value = '';
|
||||
document.getElementById('modalPassword').required = true;
|
||||
document.getElementById('passwordHint').style.display = 'block';
|
||||
document.getElementById('modalRole').value = 'admin';
|
||||
document.getElementById('modalActive').checked = true;
|
||||
document.getElementById('saveUserBtn').textContent = 'Create';
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function editUser(id, username, email, role, isActive) {
|
||||
document.getElementById('modalTitle').textContent = 'Edit User';
|
||||
document.getElementById('userId').value = id;
|
||||
document.getElementById('modalUsername').value = username;
|
||||
document.getElementById('modalEmail').value = email;
|
||||
document.getElementById('modalPassword').value = '';
|
||||
document.getElementById('modalPassword').required = false;
|
||||
document.getElementById('passwordHint').style.display = 'none';
|
||||
document.getElementById('modalRole').value = role;
|
||||
document.getElementById('modalActive').checked = !!isActive;
|
||||
document.getElementById('saveUserBtn').textContent = 'Update';
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveUser() {
|
||||
const id = document.getElementById('userId').value;
|
||||
const formData = new FormData(document.getElementById('userForm'));
|
||||
|
||||
const isEdit = !!id;
|
||||
const url = isEdit ? '/admin/users/' + id + '/update' : '/admin/users/create';
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: new URLSearchParams(formData),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', data.message);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast('error', data.message);
|
||||
}
|
||||
closeModal();
|
||||
})
|
||||
.catch(err => showToast('error', 'Failed to save user'));
|
||||
}
|
||||
|
||||
function deleteUser(id, username) {
|
||||
if (!confirm('Are you sure you want to delete user "' + username + '"?')) return;
|
||||
|
||||
fetch('/admin/users/' + id + '/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(document.querySelector('meta[name="csrf-token"]')?.content || ''),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) location.reload();
|
||||
else showToast('error', data.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/admin-users.js"></script>
|
||||
|
||||
@@ -164,144 +164,6 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
const aggregatedData = <?= json_encode($aggregatedMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
|
||||
|
||||
let aggregatedChart = null;
|
||||
|
||||
function initAggregatedChart(data) {
|
||||
const container = document.getElementById('aggregatedChart').parentNode;
|
||||
const oldCanvas = document.getElementById('aggregatedChart');
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.id = 'aggregatedChart';
|
||||
newCanvas.height = 300;
|
||||
container.replaceChild(newCanvas, oldCanvas);
|
||||
const ctx = newCanvas.getContext('2d');
|
||||
const labels = data.map(m => {
|
||||
const d = new Date(m.time_bucket);
|
||||
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
|
||||
});
|
||||
|
||||
aggregatedChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU',
|
||||
data: data.map(m => parseFloat(m.avg_cpu)),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
{
|
||||
label: 'RAM',
|
||||
data: data.map(m => parseFloat(m.avg_ram)),
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
{
|
||||
label: 'Disk',
|
||||
data: data.map(m => parseFloat(m.avg_disk)),
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#9ca3af',
|
||||
font: { family: "'Inter', sans-serif" },
|
||||
boxWidth: 12,
|
||||
padding: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1a1d2b',
|
||||
borderColor: '#2a2d3d',
|
||||
borderWidth: 1,
|
||||
titleColor: '#e1e4ed',
|
||||
bodyColor: '#9ca3af',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
maxTicksLimit: 12,
|
||||
font: { size: 11 },
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
font: { size: 11 },
|
||||
callback: function(v) { return v + '%'; },
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAggregatedChart() {
|
||||
fetch('/dashboard/chart-data')
|
||||
.then(r => r.json())
|
||||
.then(response => {
|
||||
if (response.success && response.data) {
|
||||
const data = response.data;
|
||||
const labels = data.map(m => {
|
||||
const d = new Date(m.time_bucket);
|
||||
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
|
||||
});
|
||||
if (aggregatedChart) {
|
||||
aggregatedChart.data.labels = labels;
|
||||
aggregatedChart.data.datasets[0].data = data.map(m => parseFloat(m.avg_cpu));
|
||||
aggregatedChart.data.datasets[1].data = data.map(m => parseFloat(m.avg_ram));
|
||||
aggregatedChart.data.datasets[2].data = data.map(m => parseFloat(m.avg_disk));
|
||||
aggregatedChart.update('none');
|
||||
} else if (data.length > 0) {
|
||||
initAggregatedChart(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (aggregatedData.length > 0) {
|
||||
initAggregatedChart(aggregatedData);
|
||||
}
|
||||
|
||||
setInterval(refreshAggregatedChart, 30000);
|
||||
</script>
|
||||
<script>const aggregatedData = <?= json_encode($aggregatedMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;</script>
|
||||
<script src="/assets/js/dashboard-chart.js"></script>
|
||||
</div>
|
||||
|
||||
203
views/errors/429.php
Normal file
203
views/errors/429.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
$retryMinutes = $retryMinutes ?? 1;
|
||||
$retryAfterMs = $retryAfterMs ?? 60000;
|
||||
$requestUri = htmlspecialchars($requestUri ?? '/', ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>429 Too Many Requests — ServerManager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--bg-primary: #0a0c10;
|
||||
--bg-secondary: #111318;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--text-primary: #e1e4ed;
|
||||
--text-muted: #7a7f8e;
|
||||
--border-color: #1e2030;
|
||||
--warning: #f59e0b;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rate-limit-card {
|
||||
text-align: center;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.icon-shield {
|
||||
width: 80px; height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: radial-gradient(circle, rgba(245,158,11,0.12) 0%, transparent 70%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 2.4rem;
|
||||
color: var(--warning);
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.error-code {
|
||||
font-size: 5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--warning), #f97316);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.error-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.error-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.countdown-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.countdown-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.countdown-timer {
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--warning);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.countdown-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
margin-top: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.countdown-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--warning), #f97316);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 2rem;
|
||||
background: linear-gradient(135deg, var(--accent), #818cf8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-primary.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.btn-primary.active:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
|
||||
}
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="rate-limit-card">
|
||||
<div class="icon-shield"><i class="fas fa-shield-halved"></i></div>
|
||||
<div class="error-code">429</div>
|
||||
<div class="error-title">Too Many Requests</div>
|
||||
<div class="error-desc">You have made too many requests in a short period. Please wait before trying again.</div>
|
||||
<div class="countdown-box">
|
||||
<div class="countdown-label">Retry in</div>
|
||||
<div class="countdown-timer" id="countdown"><?= $retryMinutes ?>:00</div>
|
||||
<div class="countdown-bar"><div class="countdown-bar-fill" id="progressBar"></div></div>
|
||||
</div>
|
||||
<a href="<?= $requestUri ?>" class="btn-primary" id="retryBtn"><i class="fas fa-rotate-right"></i> Try Again</a>
|
||||
<br>
|
||||
<a href="/dashboard" class="btn-secondary"><i class="fas fa-gauge-high"></i> Go to Dashboard</a>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var total = <?= $retryAfterMs ?>;
|
||||
var remaining = total;
|
||||
var el = document.getElementById('countdown');
|
||||
var bar = document.getElementById('progressBar');
|
||||
var btn = document.getElementById('retryBtn');
|
||||
var start = Date.now();
|
||||
|
||||
function tick() {
|
||||
var elapsed = Date.now() - start;
|
||||
remaining = Math.max(0, total - elapsed);
|
||||
var secs = Math.ceil(remaining / 1000);
|
||||
var m = Math.floor(secs / 60);
|
||||
var s = secs % 60;
|
||||
el.textContent = m + ':' + (s < 10 ? '0' : '') + s;
|
||||
bar.style.width = (remaining / total * 100) + '%';
|
||||
|
||||
if (remaining <= 0) {
|
||||
el.textContent = '0:00';
|
||||
bar.style.width = '0%';
|
||||
btn.classList.add('active');
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,217 +1,155 @@
|
||||
<?php
|
||||
$user = $user ?? null;
|
||||
$notifications = $notifications ?? [];
|
||||
$unreadCount = $unreadCount ?? 0;
|
||||
$teamId = $teamId ?? null;
|
||||
$title = $title ?? 'ServerManager';
|
||||
$content = $content ?? '';
|
||||
|
||||
$currentRoute = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="<?= $_SESSION['csrf_token'] ?? '' ?>">
|
||||
<meta name="api-token" content="<?php
|
||||
$uid = $_SESSION['user_id'] ?? 0;
|
||||
$token = '';
|
||||
if ($uid) {
|
||||
$u = \ServerManager\Core\Database::getInstance()->fetch(
|
||||
'SELECT api_token FROM users WHERE id = ? AND status = \'active\'', [(int) $uid]
|
||||
);
|
||||
$token = $u['api_token'] ?? '';
|
||||
}
|
||||
echo htmlspecialchars($token);
|
||||
?>">
|
||||
<title><?= htmlspecialchars($title ?? 'ServerManager') ?></title>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/server.svg">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<title><?= htmlspecialchars($title) ?> — ServerManager</title>
|
||||
<meta name="api-token" content="<?= htmlspecialchars($user['api_token'] ?? '') ?>">
|
||||
<meta name="csrf-token" content="<?= $this->csrfToken() ?>">
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a href="/dashboard" class="sidebar-logo">
|
||||
<i class="fas fa-server"></i>
|
||||
<span class="logo-text">ServerManager</span>
|
||||
<a href="/dashboard" class="sidebar-brand">
|
||||
<i class="fas fa-cubes"></i>
|
||||
<span>ServerManager</span>
|
||||
</a>
|
||||
<button class="sidebar-toggle" id="sidebarToggle" title="Toggle sidebar">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item">
|
||||
<a href="/dashboard" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/dashboard') ? 'active' : '' ?>">
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
<!-- Dashboard -->
|
||||
<a href="/dashboard" class="nav-item <?= str_starts_with($currentRoute, '/dashboard') ? 'active' : '' ?>">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/notifications" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/notifications') ? 'active' : '' ?>">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span>Notifications</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item nav-item-accordion <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/servers') ? 'active' : '' ?>">
|
||||
<a href="#" class="nav-link" onclick="toggleServerList(event)">
|
||||
<i class="fas fa-hdd"></i>
|
||||
|
||||
<!-- Servers -->
|
||||
<div class="nav-item-accordion <?= str_starts_with($currentRoute, '/servers') || str_starts_with($currentRoute, '/terminal') ? 'expanded' : '' ?>">
|
||||
<a href="#" class="nav-item nav-item-toggle <?= str_starts_with($currentRoute, '/servers') ? 'active' : '' ?>" onclick="toggleServerList(event)">
|
||||
<i class="fas fa-server"></i>
|
||||
<span>Servers</span>
|
||||
<i class="fas fa-chevron-right nav-chevron"></i>
|
||||
</a>
|
||||
<div class="nav-submenu" id="serverList">
|
||||
<div class="nav-submenu-loading"><i class="fas fa-spinner fa-spin"></i></div>
|
||||
<div class="nav-sublist" id="serverList" style="<?= str_starts_with($currentRoute, '/servers') || str_starts_with($currentRoute, '/terminal') ? '' : '' ?>">
|
||||
<a href="/servers" class="nav-sub-item"><i class="fas fa-list"></i> All Servers</a>
|
||||
</div>
|
||||
</li>
|
||||
<?php if (is_super_admin()): ?>
|
||||
<li class="nav-divider"></li>
|
||||
<li class="nav-section">Administration</li>
|
||||
<li class="nav-item">
|
||||
<a href="/teams" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/teams') ? 'active' : '' ?>">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Teams</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/admin/users" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/users') ? 'active' : '' ?>">
|
||||
</div>
|
||||
|
||||
<!-- Teams -->
|
||||
<a href="/teams" class="nav-item <?= str_starts_with($currentRoute, '/teams') ? 'active' : '' ?>">
|
||||
<i class="fas fa-users"></i>
|
||||
<span>Users</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/admin/audit" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/audit') ? 'active' : '' ?>">
|
||||
<i class="fas fa-history"></i>
|
||||
<span>Audit Logs</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/admin/security" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/security') ? 'active' : '' ?>">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>Security</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/admin/app-version" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/app-version') ? 'active' : '' ?>">
|
||||
<i class="fas fa-mobile-alt"></i>
|
||||
<span>App Version</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/admin/notifications" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/notifications') ? 'active' : '' ?>">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span>Notifications</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/admin/policies" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/policies') ? 'active' : '' ?>">
|
||||
<i class="fas fa-file-contract"></i>
|
||||
<span>Policies</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php elseif (is_admin()): ?>
|
||||
<li class="nav-divider"></li>
|
||||
<li class="nav-section">Administration</li>
|
||||
<li class="nav-item">
|
||||
<a href="/teams" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/teams') ? 'active' : '' ?>">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>Teams</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li class="nav-divider"></li>
|
||||
<li class="nav-section">Mobile</li>
|
||||
<li class="nav-item">
|
||||
<a href="/sysadmin.apk" class="nav-link" download>
|
||||
<i class="fas fa-download"></i>
|
||||
<span>Download APK</span>
|
||||
|
||||
<!-- Activity Log (admin+) -->
|
||||
<?php if (is_admin() || is_super_admin()): ?>
|
||||
<a href="/admin/activity" class="nav-item <?= str_starts_with($currentRoute, '/admin/activity') ? 'active' : '' ?>">
|
||||
<i class="fas fa-history"></i>
|
||||
<span>Activity Log</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Admin panel (admin+) -->
|
||||
<?php if (is_admin() || is_super_admin()): ?>
|
||||
<a href="/admin" class="nav-item <?= str_starts_with($currentRoute, '/admin') ? 'active' : '' ?>">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Administration</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<hr class="nav-divider">
|
||||
|
||||
<!-- Account -->
|
||||
<a href="/account" class="nav-item <?= str_starts_with($currentRoute, '/account') ? 'active' : '' ?>">
|
||||
<i class="fas fa-user"></i>
|
||||
<span>My Account</span>
|
||||
</a>
|
||||
|
||||
<a href="/logout" class="nav-item nav-item-danger">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>Log out</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
<?= strtoupper(substr($_SESSION['username'] ?? '?', 0, 2)) ?>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<span class="user-name"><?= htmlspecialchars($_SESSION['username'] ?? 'Guest') ?></span>
|
||||
<span class="user-role"><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $_SESSION['user_role'] ?? 'guest'))) ?></span>
|
||||
</div>
|
||||
<div class="user-menu">
|
||||
<a href="/profile" title="Profile"><i class="fas fa-user-cog"></i></a>
|
||||
<form method="POST" action="/logout" class="logout-form" style="display:inline">
|
||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
||||
<button type="submit" title="Logout" style="background:none;border:none;color:inherit;cursor:pointer;padding:0;font:inherit;line-height:1"><i class="fas fa-sign-out-alt"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="top-bar">
|
||||
<div class="top-bar-left">
|
||||
<button class="mobile-toggle" id="mobileToggle">
|
||||
<!-- Main Content -->
|
||||
<div class="main-area">
|
||||
<!-- Topbar -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="btn btn-icon" id="sidebarToggle" onclick="document.getElementById('sidebar').classList.toggle('collapsed')">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="breadcrumb">
|
||||
<?= htmlspecialchars($title ?? 'ServerManager') ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<div class="top-bar-actions">
|
||||
<button class="btn-icon" id="refreshBtn" title="Refresh">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<button class="btn-icon" id="themeToggle" title="Toggle theme">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
<div class="topbar-right">
|
||||
<div class="notification-btn-wrapper">
|
||||
<button class="btn-icon notification-btn" id="notificationBell" title="Notifications" onclick="toggleNotifications()">
|
||||
<button class="btn btn-icon notification-btn" onclick="toggleNotifications()" title="Notifications">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span class="notification-badge" id="notificationBadge" style="display:none">0</span>
|
||||
<span class="notification-badge" id="notificationBadge" style="display:<?= $unreadCount > 0 ? 'inline-flex' : 'none' ?>">
|
||||
<?= $unreadCount > 99 ? '99+' : $unreadCount ?>
|
||||
</span>
|
||||
</button>
|
||||
<div class="notification-dropdown" id="notifDropdown">
|
||||
<div class="notif-dropdown-header">
|
||||
<span style="font-weight:600;font-size:0.9em">Notifications</span>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<button class="btn-text" id="markAllReadBtn" onclick="markAllRead()" style="display:none;font-size:0.78em;padding:2px 6px;border-radius:4px">
|
||||
<div class="notif-header">
|
||||
<h3>Notifications</h3>
|
||||
<button class="btn btn-text btn-sm" id="markAllReadBtn" onclick="markAllRead()"
|
||||
style="display:<?= $unreadCount > 0 ? 'inline-flex' : 'none' ?>">
|
||||
<i class="fas fa-check-double"></i> Mark all read
|
||||
</button>
|
||||
<button class="btn-text" onclick="closeNotifications()" style="font-size:0.78em;padding:2px 6px;border-radius:4px">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-dropdown-body" id="notifList">
|
||||
<div class="notif-list" id="notifList">
|
||||
<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">
|
||||
<i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i>
|
||||
<span>Loading notifications...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-dropdown-footer">
|
||||
<a href="/notifications" class="btn-text" style="font-size:0.82em;color:var(--info);width:100%;text-align:center;padding:8px;display:block">
|
||||
<i class="fas fa-external-link-alt"></i> View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="topbar-user">
|
||||
<i class="fas fa-user-circle"></i>
|
||||
<?= htmlspecialchars($user['username'] ?? 'Guest') ?>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-area">
|
||||
<?php if ($flash = \ServerManager\Core\Session::getAllFlashes()): ?>
|
||||
<?php foreach ($flash as $type => $messages): ?>
|
||||
<?php foreach ($messages as $message): ?>
|
||||
<div class="toast toast-<?= htmlspecialchars($type) ?>" data-autohide="true">
|
||||
<div class="toast-body">
|
||||
<i class="fas fa-<?= $type === 'success' ? 'check-circle' : ($type === 'error' ? 'times-circle' : 'info-circle') ?>"></i>
|
||||
<?= htmlspecialchars($message) ?>
|
||||
</div>
|
||||
<button class="toast-close">×</button>
|
||||
<!-- Page Content -->
|
||||
<main class="page-content">
|
||||
<div class="container">
|
||||
<?php if (isset($_SESSION['_flash'])): ?>
|
||||
<?php foreach ($_SESSION['_flash'] as $type => $messages): ?>
|
||||
<?php foreach ($messages as $msg): ?>
|
||||
<div class="alert alert-<?= $type === 'error' ? 'danger' : $type ?>">
|
||||
<i class="fas fa-<?= $type === 'success' ? 'check-circle' : ($type === 'error' ? 'exclamation-circle' : 'info-circle') ?>"></i>
|
||||
<?= htmlspecialchars($msg) ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php unset($_SESSION['_flash']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?= $content ?? '' ?>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
@@ -221,211 +159,6 @@
|
||||
<script src="<?= htmlspecialchars($script) ?>"></script>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<script>
|
||||
let serverListLoaded = false;
|
||||
|
||||
function getApiToken() {
|
||||
return document.querySelector('meta[name="api-token"]')?.getAttribute('content') || '';
|
||||
}
|
||||
|
||||
function toggleNotifications() {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
if (dd.classList.contains('open')) {
|
||||
closeNotifications();
|
||||
} else {
|
||||
openNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function openNotifications() {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
dd.classList.add('open');
|
||||
fetchNotifications();
|
||||
document.addEventListener('click', notifOutsideClick);
|
||||
}
|
||||
|
||||
function closeNotifications() {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
dd.classList.remove('open');
|
||||
document.removeEventListener('click', notifOutsideClick);
|
||||
}
|
||||
|
||||
function notifOutsideClick(e) {
|
||||
const wrapper = document.querySelector('.notification-btn-wrapper');
|
||||
if (!wrapper.contains(e.target)) {
|
||||
closeNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function fetchNotifications() {
|
||||
const list = document.getElementById('notifList');
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)"><i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i><span>Loading notifications...</span></div>';
|
||||
|
||||
const token = getApiToken();
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications?per_page=10', { headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (!d.success) {
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">Could not load notifications.</div>';
|
||||
return;
|
||||
}
|
||||
renderNotifications(d.data || [], d.unread_count || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--danger)">Failed to load notifications.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(items, unreadCount) {
|
||||
const list = document.getElementById('notifList');
|
||||
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">'
|
||||
+ '<i class="fas fa-check-circle" style="font-size:1.8em;margin-bottom:10px;display:block;color:var(--success)"></i>'
|
||||
+ '<span>No notifications</span></div>';
|
||||
document.getElementById('markAllReadBtn').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
items.forEach(function(n) {
|
||||
const typeColors = { error: 'danger', warning: 'warning', info: 'info' };
|
||||
const typeColor = typeColors[n.type] || 'info';
|
||||
const icons = { error: 'fa-times-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle' };
|
||||
const icon = icons[n.type] || 'fa-info-circle';
|
||||
|
||||
html += '<div class="notif-item ' + (n.is_read ? 'notif-read' : 'notif-unread') + '"'
|
||||
+ ' onclick="markRead(' + n.id + ', this)"'
|
||||
+ ' data-id="' + n.id + '">'
|
||||
+ '<div class="notif-icon notif-' + typeColor + '"><i class="fas ' + icon + '"></i></div>'
|
||||
+ '<div class="notif-content">'
|
||||
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
|
||||
+ '<div class="notif-message">' + escapeHtml(n.message) + '</div>'
|
||||
+ '<div class="notif-time">' + (n.time_ago || '') + '</div>'
|
||||
+ '</div>'
|
||||
+ (n.is_read ? '' : '<div class="notif-dot"></div>')
|
||||
+ '</div>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
|
||||
document.getElementById('markAllReadBtn').style.display = unreadCount > 0 ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
function markRead(id, el) {
|
||||
if (el.classList.contains('notif-read')) return;
|
||||
|
||||
const token = getApiToken();
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications/' + id + '/read', { method: 'POST', headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success) {
|
||||
el.classList.remove('notif-unread');
|
||||
el.classList.add('notif-read');
|
||||
const dot = el.querySelector('.notif-dot');
|
||||
if (dot) dot.remove();
|
||||
updateUnreadCount();
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
const token = getApiToken();
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications/read-all', { method: 'POST', headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success) {
|
||||
document.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.classList.remove('notif-unread');
|
||||
el.classList.add('notif-read');
|
||||
const dot = el.querySelector('.notif-dot');
|
||||
if (dot) dot.remove();
|
||||
});
|
||||
document.getElementById('markAllReadBtn').style.display = 'none';
|
||||
updateUnreadCount();
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
function updateUnreadCount() {
|
||||
const token = getApiToken();
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications/unread-count', { headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const count = d.unread_count || 0;
|
||||
const badge = document.getElementById('notificationBadge');
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.style.display = 'inline-flex';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Poll unread count every 60s
|
||||
setInterval(updateUnreadCount, 60000);
|
||||
// Initial fetch
|
||||
updateUnreadCount();
|
||||
|
||||
function toggleServerList(e) {
|
||||
e.preventDefault();
|
||||
const item = e.currentTarget.closest('.nav-item-accordion');
|
||||
const sub = document.getElementById('serverList');
|
||||
const chevron = item.querySelector('.nav-chevron');
|
||||
if (item.classList.contains('expanded')) {
|
||||
item.classList.remove('expanded');
|
||||
sub.style.maxHeight = '0';
|
||||
return;
|
||||
}
|
||||
item.classList.add('expanded');
|
||||
if (!serverListLoaded) {
|
||||
serverListLoaded = true;
|
||||
fetch('/sidebar/servers')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success && d.data) {
|
||||
let html = '<a href="/servers" class="nav-sub-item"><i class="fas fa-list"></i> All Servers</a>';
|
||||
d.data.forEach(s => {
|
||||
const status = s.current_status === 'online' ? 'success' : s.current_status === 'offline' ? 'danger' : 'muted';
|
||||
html += '<a href="/servers/' + s.id + '" class="nav-sub-item">'
|
||||
+ '<span class="status-dot-sm status-' + status + '"></span> '
|
||||
+ escapeHtml(s.name) + '</a>';
|
||||
});
|
||||
sub.innerHTML = html;
|
||||
} else {
|
||||
sub.innerHTML = '<div class="nav-sub-item" style="color:var(--text-muted)">No servers</div>';
|
||||
}
|
||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||
})
|
||||
.catch(() => {
|
||||
sub.innerHTML = '<div class="nav-sub-item" style="color:var(--danger)">Failed to load</div>';
|
||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||
});
|
||||
} else {
|
||||
sub.style.maxHeight = sub.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -67,25 +67,4 @@ $data = $notifications ?? [];
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
function markAllRead() {
|
||||
const token = document.querySelector('meta[name="api-token"]')?.getAttribute('content') || '';
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
fetch('/api/notifications/read-all', { method: 'POST', headers })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.success) {
|
||||
document.querySelectorAll('.notif-item').forEach(function(el) {
|
||||
el.classList.remove('notif-unread');
|
||||
el.classList.add('notif-read');
|
||||
const dot = el.querySelector('.notif-dot');
|
||||
if (dot) dot.remove();
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function(){});
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/notifications.js"></script>
|
||||
|
||||
@@ -1,462 +1,4 @@
|
||||
<style>
|
||||
body.auth-page {
|
||||
display: block;
|
||||
align-items: unset;
|
||||
justify-content: unset;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
body.auth-page .auth-container {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.landing-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.landing-hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: radial-gradient(ellipse at 50% 30%, rgba(99,102,241,0.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(34,197,94,0.05) 0%, transparent 50%),
|
||||
var(--bg-primary);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.landing-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(1px 1px at 20% 30%, rgba(255,255,255,0.15) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 40% 70%, rgba(255,255,255,0.12) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 60% 20%, rgba(255,255,255,0.15) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 80% 60%, rgba(255,255,255,0.12) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 10% 80%, rgba(255,255,255,0.08) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 90% 10%, rgba(255,255,255,0.08) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-elements {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.floating-elem {
|
||||
position: absolute;
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.08;
|
||||
animation: floatAround 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.floating-elem:nth-child(1) { top: 15%; left: 10%; animation-delay: 0s; font-size: 2rem; }
|
||||
.floating-elem:nth-child(2) { top: 25%; right: 12%; animation-delay: -3s; }
|
||||
.floating-elem:nth-child(3) { top: 60%; left: 8%; animation-delay: -7s; font-size: 1.8rem; }
|
||||
.floating-elem:nth-child(4) { top: 70%; right: 8%; animation-delay: -11s; }
|
||||
.floating-elem:nth-child(5) { top: 40%; left: 5%; animation-delay: -5s; font-size: 1.2rem; }
|
||||
.floating-elem:nth-child(6) { top: 50%; right: 5%; animation-delay: -9s; font-size: 1.6rem; }
|
||||
.floating-elem:nth-child(7) { top: 10%; left: 50%; animation-delay: -13s; font-size: 1rem; }
|
||||
.floating-elem:nth-child(8) { top: 85%; left: 50%; animation-delay: -15s; }
|
||||
|
||||
@keyframes floatAround {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-30px) rotate(5deg); }
|
||||
50% { transform: translateY(0) rotate(0deg); }
|
||||
75% { transform: translateY(20px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
.hero-icon-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
animation: heroIconEnter 1s ease-out;
|
||||
}
|
||||
|
||||
.hero-server-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(99,102,241,0.05));
|
||||
border: 1px solid rgba(99,102,241,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.4rem;
|
||||
color: var(--accent);
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-server-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
opacity: 0.15;
|
||||
z-index: -1;
|
||||
animation: glowPulse 3s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.15; transform: scale(1); }
|
||||
50% { opacity: 0.3; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes heroIconEnter {
|
||||
from { opacity: 0; transform: translateY(-30px) scale(0.8); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 0.75rem;
|
||||
animation: fadeSlideUp 0.8s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.hero-title .gradient-text {
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: clamp(1rem, 2vw, 1.2rem);
|
||||
color: var(--text-muted);
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
animation: fadeSlideUp 0.8s ease-out 0.35s both;
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
animation: fadeSlideUp 0.8s ease-out 0.5s both;
|
||||
}
|
||||
|
||||
.hero-cta .btn {
|
||||
padding: 0.85rem 2.2rem;
|
||||
font-size: 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-cta .btn-primary-custom {
|
||||
background: linear-gradient(135deg, var(--accent), #818cf8);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
|
||||
}
|
||||
|
||||
.hero-cta .btn-primary-custom:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 28px rgba(99,102,241,0.4);
|
||||
}
|
||||
|
||||
.hero-cta .btn-secondary-custom {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hero-cta .btn-secondary-custom:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero-terminal {
|
||||
margin-top: 3rem;
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
animation: fadeSlideUp 0.8s ease-out 0.65s both;
|
||||
}
|
||||
|
||||
.terminal-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.terminal-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.terminal-dot.red { background: #ff5f57; }
|
||||
.terminal-dot.yellow { background: #ffbd2e; }
|
||||
.terminal-dot.green { background: #28c840; }
|
||||
|
||||
.terminal-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.terminal-body-landing {
|
||||
padding: 1.25rem 1.25rem 1.5rem;
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.terminal-line-landing {
|
||||
opacity: 0;
|
||||
animation: terminalFadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.terminal-line-landing .prompt { color: var(--success); }
|
||||
.terminal-line-landing .cmd { color: #e1e4ed; }
|
||||
.terminal-line-landing .output-success { color: var(--success); }
|
||||
.terminal-line-landing .output-info { color: var(--info); }
|
||||
.terminal-line-landing .output-muted { color: var(--text-muted); }
|
||||
.terminal-line-landing .blinking-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
background: var(--accent);
|
||||
margin-left: 2px;
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes terminalFadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeSlideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
animation: fadeSlideUp 1s ease-out 1.5s both;
|
||||
}
|
||||
|
||||
.scroll-indicator .mouse {
|
||||
width: 24px;
|
||||
height: 38px;
|
||||
border: 2px solid var(--text-muted);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.scroll-indicator .mouse::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 2px;
|
||||
animation: scrollWheel 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes scrollWheel {
|
||||
0% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
100% { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
}
|
||||
|
||||
.features-section {
|
||||
padding: 5rem 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.features-header h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.features-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.75rem;
|
||||
transition: all 0.4s ease-out;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.feature-card.visible:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.feature-icon.blue { background: rgba(59,130,246,0.15); color: var(--info); }
|
||||
.feature-icon.green { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.feature-icon.purple { background: rgba(139,92,246,0.15); color: var(--purple); }
|
||||
.feature-icon.orange { background: rgba(245,158,11,0.15); color: var(--warning); }
|
||||
.feature-icon.red { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||||
.feature-icon.teal { background: rgba(20,184,166,0.15); color: #14b8a6; }
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
padding: 4rem 2rem 5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 3rem;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: all 0.5s ease-out;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.landing-footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.landing-footer a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.landing-footer a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title { font-size: 1.8rem; }
|
||||
.features-grid { grid-template-columns: 1fr; }
|
||||
.stats-grid { gap: 2rem; }
|
||||
.stats-grid .stat-item { min-width: 120px; }
|
||||
.scroll-indicator { display: none; }
|
||||
.landing-hero { padding-bottom: 5rem; }
|
||||
}
|
||||
</style>
|
||||
<?php ?><link rel="stylesheet" href="/assets/css/landing.css">
|
||||
|
||||
<div class="landing-page">
|
||||
<section class="landing-hero">
|
||||
@@ -465,10 +7,10 @@ body.auth-page .auth-container {
|
||||
<div class="floating-elem"><i class="fas fa-terminal"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-database"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-shield-alt"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-hdd"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-network-wired"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-cloud"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-microchip"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-code-branch"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-chart-line"></i></div>
|
||||
<div class="floating-elem"><i class="fas fa-lock"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="hero-icon-wrapper">
|
||||
@@ -478,59 +20,37 @@ body.auth-page .auth-container {
|
||||
</div>
|
||||
|
||||
<h1 class="hero-title">
|
||||
Manage Your Servers<br>
|
||||
<span class="gradient-text">From One Place</span>
|
||||
Manage your <span class="gradient-text">servers</span> with ease
|
||||
</h1>
|
||||
|
||||
<p class="hero-subtitle">
|
||||
ServerManager is a powerful, web-based platform for managing Linux servers.
|
||||
Monitor, control, and deploy across your entire infrastructure from a single dashboard.
|
||||
A self-hosted, open-source platform for remote server management.
|
||||
Monitor, maintain, and control your infrastructure from a clean web dashboard.
|
||||
</p>
|
||||
|
||||
<div class="hero-cta">
|
||||
<a href="/login" class="btn btn-primary-custom">
|
||||
<i class="fas fa-sign-in-alt"></i> Sign In
|
||||
<i class="fas fa-arrow-right"></i> Get Started
|
||||
</a>
|
||||
<a href="/register" class="btn btn-secondary-custom">
|
||||
<i class="fas fa-user-plus"></i> Create Account
|
||||
<a href="https://github.com/devlab-lat/server-manager" class="btn btn-secondary-custom" target="_blank">
|
||||
<i class="fab fa-github"></i> View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Terminal demo -->
|
||||
<div class="hero-terminal">
|
||||
<div class="terminal-bar">
|
||||
<div class="terminal-dot red"></div>
|
||||
<div class="terminal-dot yellow"></div>
|
||||
<div class="terminal-dot green"></div>
|
||||
<span class="terminal-title">servermanager — ssh-session</span>
|
||||
<span class="terminal-dot red"></span>
|
||||
<span class="terminal-dot yellow"></span>
|
||||
<span class="terminal-dot green"></span>
|
||||
<span class="terminal-title">server-manager@dashboard:~</span>
|
||||
</div>
|
||||
<div class="terminal-body-landing">
|
||||
<div class="terminal-line-landing" style="animation-delay: 0.9s">
|
||||
<span class="prompt">admin@servermanager:~$</span>
|
||||
<span class="cmd"> systemctl status --all</span>
|
||||
</div>
|
||||
<div class="terminal-line-landing" style="animation-delay: 1.1s">
|
||||
<span class="output-success"> ✔ nginx active (running)</span>
|
||||
</div>
|
||||
<div class="terminal-line-landing" style="animation-delay: 1.2s">
|
||||
<span class="output-success"> ✔ mysql active (running)</span>
|
||||
</div>
|
||||
<div class="terminal-line-landing" style="animation-delay: 1.3s">
|
||||
<span class="output-success"> ✔ sshd active (running)</span>
|
||||
</div>
|
||||
<div class="terminal-line-landing" style="animation-delay: 1.4s">
|
||||
<span class="output-info"> ○ redis inactive (dead)</span>
|
||||
</div>
|
||||
<div class="terminal-line-landing" style="animation-delay: 1.5s">
|
||||
<span class="prompt">admin@servermanager:~$</span>
|
||||
<span class="cmd"> systemctl start redis</span>
|
||||
</div>
|
||||
<div class="terminal-line-landing" style="animation-delay: 1.7s">
|
||||
<span class="output-success"> ✔ redis active (running)</span>
|
||||
</div>
|
||||
<div class="terminal-line-landing" style="animation-delay: 1.8s">
|
||||
<span class="output-muted">All services operational.</span>
|
||||
<span class="blinking-cursor"></span>
|
||||
</div>
|
||||
<div class="terminal-line-landing" style="animation-delay:0.1s"><span class="prompt">$</span> <span class="cmd">serverman list --servers</span></div>
|
||||
<div class="terminal-line-landing" style="animation-delay:0.5s"><span class="output-success">✓</span> <span class="output-info">3 servers online</span> <span class="output-muted">· 0 offline</span></div>
|
||||
<div class="terminal-line-landing" style="animation-delay:0.9s"><span class="prompt">$</span> <span class="cmd">serverman status web-01</span></div>
|
||||
<div class="terminal-line-landing" style="animation-delay:1.3s"><span class="output-success">✓</span> <span class="output-info">web-01</span> <span class="output-muted">· CPU 23% · RAM 1.2/4G · Uptime 14d</span></div>
|
||||
<div class="terminal-line-landing" style="animation-delay:1.7s"><span class="prompt">$</span> <span class="cmd"><span class="blinking-cursor"></span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,76 +60,127 @@ body.auth-page .auth-container {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features section -->
|
||||
<section class="features-section" id="features">
|
||||
<div class="features-header">
|
||||
<h2>Everything You Need</h2>
|
||||
<p>Complete server management toolkit at your fingertips</p>
|
||||
<h2>Everything you need to manage servers</h2>
|
||||
<p>A comprehensive toolkit for sysadmins and developers.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon blue"><i class="fas fa-terminal"></i></div>
|
||||
<h3>SSH Terminal</h3>
|
||||
<p>Browser-based SSH terminal with real-time output, command history, and multi-session support.</p>
|
||||
<h3>Remote SSH Terminal</h3>
|
||||
<p>Browser-based SSH client with session persistence, tab support, and full terminal emulation.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon green"><i class="fas fa-chart-line"></i></div>
|
||||
<h3>Real-Time Monitoring</h3>
|
||||
<p>Monitor CPU, memory, disk usage, and network traffic with live metrics and historical charts.</p>
|
||||
<h3>Real-time Monitoring</h3>
|
||||
<p>Track CPU, memory, disk usage, and network activity with live-updating graphs and alerts.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon purple"><i class="fas fa-shield-alt"></i></div>
|
||||
<h3>SSL Management</h3>
|
||||
<p>Automate SSL certificate issuance and renewal via Let's Encrypt with full control.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon orange"><i class="fas fa-globe"></i></div>
|
||||
<h3>Nginx Manager</h3>
|
||||
<p>Create, edit, and manage Nginx server blocks with syntax checking and site toggling.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon red"><i class="fas fa-cogs"></i></div>
|
||||
<h3>Service Control</h3>
|
||||
<p>Start, stop, restart, and monitor system services across all your servers.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon teal"><i class="fas fa-users-cog"></i></div>
|
||||
<div class="feature-icon purple"><i class="fas fa-users-cog"></i></div>
|
||||
<h3>Team Collaboration</h3>
|
||||
<p>Assign role-based access to team members and delegate server management securely.</p>
|
||||
<p>Multi-user support with role-based access control. Share servers and manage permissions.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon orange"><i class="fas fa-bell"></i></div>
|
||||
<h3>Smart Notifications</h3>
|
||||
<p>Get push notifications and email alerts when servers go down, resources spike, or backups complete.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon red"><i class="fas fa-history"></i></div>
|
||||
<h3>Audit Logging</h3>
|
||||
<p>Every action is logged with timestamps and user attribution for full compliance and traceability.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon teal"><i class="fas fa-shield-alt"></i></div>
|
||||
<h3>Secure by Design</h3>
|
||||
<p>AES-256 encrypted credentials, CSRF protection, rate limiting, and session management built in.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-section" id="stats">
|
||||
<!-- Stats Section -->
|
||||
<section class="stats-section">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number" data-target="<?= (int) ($stats['servers'] ?? 0) ?>"><?= (int) ($stats['servers'] ?? 0) ?></div>
|
||||
<div class="stat-number" data-target="15">0</div>
|
||||
<div class="stat-label">Servers Managed</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number" data-target="<?= (int) ($stats['users'] ?? 0) ?>"><?= (int) ($stats['users'] ?? 0) ?></div>
|
||||
<div class="stat-number" data-target="99.9">0</div>
|
||||
<div class="stat-label">Uptime %</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number" data-target="7">0</div>
|
||||
<div class="stat-label">Active Users</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number" data-target="<?= (int) ($stats['online_servers'] ?? 0) ?>"><?= (int) ($stats['online_servers'] ?? 0) ?></div>
|
||||
<div class="stat-label">Servers Online</div>
|
||||
<div class="stat-number" data-target="24">0</div>
|
||||
<div class="stat-label">Hour Monitoring</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="landing-footer">
|
||||
<div class="footer-links">
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms & Conditions</a>
|
||||
<a href="/terms">Terms</a>
|
||||
<a href="/privacy">Privacy</a>
|
||||
<a href="https://github.com/devlab-lat/server-manager">GitHub</a>
|
||||
</div>
|
||||
<p>© <?= date('Y') ?> ServerManager. All rights reserved.</p>
|
||||
<span>© <?= date('Y') ?> ServerManager. Released under the MIT License.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Page-specific JS -->
|
||||
<script>
|
||||
(function() {
|
||||
document.querySelectorAll('.stat-number[data-target]').forEach(function(el) {
|
||||
var target = parseInt(el.getAttribute('data-target'), 10);
|
||||
el.textContent = target;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Animated counter
|
||||
const counters = document.querySelectorAll('.stat-number');
|
||||
|
||||
function animateCounter(counter) {
|
||||
const target = parseFloat(counter.dataset.target);
|
||||
const duration = 2000;
|
||||
const startTime = performance.now();
|
||||
const startValue = 0;
|
||||
|
||||
function update(currentTime) {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const current = startValue + (target - startValue) * eased;
|
||||
|
||||
counter.textContent = target % 1 === 0 ? Math.round(current) : current.toFixed(1);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(update);
|
||||
} else {
|
||||
counter.textContent = target % 1 === 0 ? Math.round(target) : target.toFixed(1);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
// Intersection Observer for stats
|
||||
const statsSection = document.querySelector('.stats-section');
|
||||
if (statsSection) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
counters.forEach(counter => animateCounter(counter));
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
observer.observe(statsSection);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
<style>
|
||||
body.auth-page {
|
||||
display: block;
|
||||
align-items: unset;
|
||||
justify-content: unset;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body.auth-page .auth-container {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width:768px) {
|
||||
.legal-container .legal-card { width: 92% !important; padding: 1.5rem !important; }
|
||||
}
|
||||
@media (max-width:480px) {
|
||||
.legal-container .legal-card { width: 96% !important; padding: 1rem !important; }
|
||||
}
|
||||
</style>
|
||||
<?php ?><link rel="stylesheet" href="/assets/css/legal.css">
|
||||
<div class="legal-page">
|
||||
<div class="legal-container">
|
||||
<div class="legal-card" style="width:85%;margin:3rem auto;padding:3rem 3.5rem;background:var(--card-bg);border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,0.18)">
|
||||
@@ -29,24 +9,19 @@ body.auth-page .auth-container {
|
||||
<?php
|
||||
$lines = explode("\n", $content ?? '');
|
||||
foreach ($lines as $line) {
|
||||
$line = htmlspecialchars($line);
|
||||
if (preg_match('/^[A-Z][A-Z\s]+$/', trim($line))) {
|
||||
echo '<h2 style="color:var(--accent-primary);font-size:1.2rem;margin:2rem 0 1rem;font-weight:600">' . $line . '</h2>';
|
||||
} elseif (preg_match('/^[a-z]\)\s/', trim($line))) {
|
||||
echo '<p style="margin:0.4rem 0 0.4rem 2rem">' . $line . '</p>';
|
||||
} elseif (preg_match('/^\d+\./', trim($line))) {
|
||||
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1.2rem 0 0.5rem;font-weight:600">' . $line . '</h3>';
|
||||
} elseif (trim($line) === '') {
|
||||
echo '<div style="height:0.5rem"></div>';
|
||||
$trimmed = trim($line);
|
||||
if ($trimmed === '') {
|
||||
echo '<br>';
|
||||
} elseif (str_starts_with($trimmed, '# ')) {
|
||||
echo '<h2 style="margin:2rem 0 0.75rem;font-size:1.2rem;font-weight:600;color:var(--text-primary)">' . htmlspecialchars(substr($trimmed, 2)) . '</h2>';
|
||||
} elseif (str_starts_with($trimmed, '## ')) {
|
||||
echo '<h3 style="margin:1.5rem 0 0.5rem;font-size:1.05rem;font-weight:600;color:var(--text-primary)">' . htmlspecialchars(substr($trimmed, 3)) . '</h3>';
|
||||
} else {
|
||||
echo '<p style="margin:0.4rem 0">' . $line . '</p>';
|
||||
echo '<p style="margin-bottom:0.75rem">' . htmlspecialchars($trimmed) . '</p>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<div class="legal-footer" style="margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--border-color);text-align:center">
|
||||
<p><a href="/" style="color:var(--accent-primary);text-decoration:none;font-size:0.95rem">← Back to ServerManager</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
<style>
|
||||
body.auth-page {
|
||||
display: block;
|
||||
align-items: unset;
|
||||
justify-content: unset;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body.auth-page .auth-container {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width:768px) {
|
||||
.legal-container .legal-card { width: 92% !important; padding: 1.5rem !important; }
|
||||
}
|
||||
@media (max-width:480px) {
|
||||
.legal-container .legal-card { width: 96% !important; padding: 1rem !important; }
|
||||
}
|
||||
</style>
|
||||
<?php ?><link rel="stylesheet" href="/assets/css/legal.css">
|
||||
<div class="legal-page">
|
||||
<div class="legal-container">
|
||||
<div class="legal-card" style="width:85%;margin:3rem auto;padding:3rem 3.5rem;background:var(--card-bg);border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,0.18)">
|
||||
@@ -29,26 +9,19 @@ body.auth-page .auth-container {
|
||||
<?php
|
||||
$lines = explode("\n", $content ?? '');
|
||||
foreach ($lines as $line) {
|
||||
$line = htmlspecialchars($line);
|
||||
if (preg_match('/^[A-Z][A-Z\s]+$/', trim($line))) {
|
||||
echo '<h2 style="color:var(--accent-primary);font-size:1.2rem;margin:2rem 0 1rem;font-weight:600">' . $line . '</h2>';
|
||||
} elseif (preg_match('/^\d+\.\d/', trim($line))) {
|
||||
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1rem 0 0.4rem 1.5rem;font-weight:600">' . $line . '</h3>';
|
||||
} elseif (preg_match('/^\d+\./', trim($line))) {
|
||||
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1.2rem 0 0.5rem;font-weight:600">' . $line . '</h3>';
|
||||
} elseif (preg_match('/^[-–—]\s/', trim($line))) {
|
||||
echo '<p style="margin:0.4rem 0 0.4rem 2rem">' . $line . '</p>';
|
||||
} elseif (trim($line) === '') {
|
||||
echo '<div style="height:0.5rem"></div>';
|
||||
$trimmed = trim($line);
|
||||
if ($trimmed === '') {
|
||||
echo '<br>';
|
||||
} elseif (str_starts_with($trimmed, '# ')) {
|
||||
echo '<h2 style="margin:2rem 0 0.75rem;font-size:1.2rem;font-weight:600;color:var(--text-primary)">' . htmlspecialchars(substr($trimmed, 2)) . '</h2>';
|
||||
} elseif (str_starts_with($trimmed, '## ')) {
|
||||
echo '<h3 style="margin:1.5rem 0 0.5rem;font-size:1.05rem;font-weight:600;color:var(--text-primary)">' . htmlspecialchars(substr($trimmed, 3)) . '</h3>';
|
||||
} else {
|
||||
echo '<p style="margin:0.4rem 0">' . $line . '</p>';
|
||||
echo '<p style="margin-bottom:0.75rem">' . htmlspecialchars($trimmed) . '</p>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<div class="legal-footer" style="margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--border-color);text-align:center">
|
||||
<p><a href="/" style="color:var(--accent-primary);text-decoration:none;font-size:0.95rem">← Back to ServerManager</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
42
views/servers/create.php
Executable file → Normal file
42
views/servers/create.php
Executable file → Normal file
@@ -170,45 +170,5 @@ document.querySelectorAll('.auth-method-tabs .tab').forEach(tab => {
|
||||
});
|
||||
|
||||
let permIndex = <?= !empty($savedPerms) && is_array($savedPerms) ? count($savedPerms) : 0 ?>;
|
||||
|
||||
function createPermissionRow(type, value, description) {
|
||||
const idx = permIndex++;
|
||||
const container = document.getElementById('permissions-container');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'permission-row';
|
||||
div.dataset.index = idx;
|
||||
div.innerHTML = `
|
||||
<select name="permissions[${idx}][type]" class="form-input permission-type">
|
||||
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
|
||||
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
|
||||
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
|
||||
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
|
||||
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
|
||||
</select>
|
||||
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
|
||||
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
|
||||
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
|
||||
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
function removePermissionRow(btn) {
|
||||
btn.closest('.permission-row').remove();
|
||||
}
|
||||
|
||||
function addPresetPermission(type, value, description) {
|
||||
createPermissionRow(type, value, description);
|
||||
}
|
||||
|
||||
document.getElementById('addPermissionBtn').addEventListener('click', function() {
|
||||
createPermissionRow('command', '', '');
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/server-permissions.js"></script>
|
||||
|
||||
@@ -92,322 +92,8 @@ $status = $db['status'] ?? 'inactive';
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentDb = '';
|
||||
let currentTable = '';
|
||||
let openedDb = '';
|
||||
|
||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
||||
|
||||
function filterTables(inp) {
|
||||
const q = inp.value.toLowerCase();
|
||||
const body = inp.closest('.pma-accordion-body');
|
||||
if (body) {
|
||||
body.querySelectorAll('.pma-table-item').forEach(el => {
|
||||
el.style.display = !q || (el.dataset.tbl || '').includes(q) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function filterSidebar(q) {
|
||||
document.querySelectorAll('.pma-accordion-item').forEach(el => {
|
||||
el.style.display = el.textContent.toLowerCase().includes(q.toLowerCase()) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDb(db) {
|
||||
const item = document.querySelector(`.pma-accordion-item[data-db="${db.replace(/"/g,'\\"')}"]`);
|
||||
if (!item) return;
|
||||
const body = item.querySelector('.pma-accordion-body');
|
||||
const header = item.querySelector('.pma-accordion-header');
|
||||
const chevron = item.querySelector('.pma-chevron');
|
||||
|
||||
if (item.classList.contains('expanded')) {
|
||||
item.classList.remove('expanded');
|
||||
body.style.maxHeight = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.pma-accordion-item.expanded').forEach(el => {
|
||||
el.classList.remove('expanded');
|
||||
el.querySelector('.pma-accordion-body').style.maxHeight = '0';
|
||||
});
|
||||
|
||||
item.classList.add('expanded');
|
||||
currentDb = db;
|
||||
|
||||
if (body.querySelector('.pma-table-item')) {
|
||||
body.style.maxHeight = body.scrollHeight + 'px';
|
||||
return;
|
||||
}
|
||||
|
||||
body.querySelector('.pma-loading-tables').style.display = '';
|
||||
body.style.maxHeight = '60px';
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/database', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body: 'action=tables&db=' + encodeURIComponent(db) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
body.querySelector('.pma-loading-tables').style.display = 'none';
|
||||
let html = '<div style="padding:0.3rem 0.5rem"><input type="text" class="form-input tbl-filter" placeholder="Filter tables..." style="font-size:0.75rem;padding:0.25rem 0.4rem;width:100%;box-sizing:border-box" oninput="filterTables(this)"></div>';
|
||||
(data.tables || []).forEach(t => {
|
||||
html += '<div class="pma-table-item" data-tbl="' + escHtml(t.name).toLowerCase() + '" onclick="selectTable(\'' + db.replace(/'/g,"\\'") + "','" + t.name.replace(/'/g,"\\'") + '\')">'
|
||||
+ '<i class="fas fa-table"></i> ' + escHtml(t.name)
|
||||
+ ' <span class="pma-db-size">' + t.rows + ' rows</span></div>';
|
||||
});
|
||||
if (!html) html = '<div class="pma-table-item" style="color:var(--text-muted);cursor:default">No tables</div>';
|
||||
body.innerHTML = html;
|
||||
body.style.maxHeight = body.scrollHeight + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
function selectTable(db, table) {
|
||||
currentDb = db; currentTable = table;
|
||||
document.getElementById('tabBrowse').style.display = '';
|
||||
document.getElementById('tabStructure').style.display = '';
|
||||
document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById('tabStructure').classList.add('active');
|
||||
showPanel('structure');
|
||||
loadStructure(db, table);
|
||||
}
|
||||
|
||||
function loadStructure(db, table) {
|
||||
const panel = document.getElementById('pmaStructure');
|
||||
panel.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading structure...</div>';
|
||||
fetch('/servers/<?= $server['id'] ?>/database', {
|
||||
method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action=structure&db='+encodeURIComponent(db)+'&table='+encodeURIComponent(table)+'&_csrf_token='+encodeURIComponent(getCsrfToken()),
|
||||
}).then(r=>r.json()).then(d=>{
|
||||
if (!d.success||!d.columns) return;
|
||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-list"></i> <code>'+escHtml(table)+'</code></h2>'
|
||||
+'<span class="badge badge-info">'+d.columns.length+' columns</span>'
|
||||
+'<span style="flex:1"></span>'
|
||||
+'<button class="btn btn-xs btn-info" onclick="browseTable(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\')"><i class="fas fa-search"></i> Browse</button>'
|
||||
+'</div>';
|
||||
html+='<div class="table-responsive"><table class="table table-sm"><thead><tr><th>#</th><th>Field</th><th>Type</th><th>Collation</th><th>Null</th><th>Key</th><th>Default</th><th>Extra</th></tr></thead><tbody>';
|
||||
d.columns.forEach((c,i)=>{
|
||||
html+='<tr><td>'+(i+1)+'</td><td><strong>'+escHtml(c.field)+'</strong></td><td><code>'+escHtml(c.type)+'</code></td>'
|
||||
+'<td>'+escHtml(c.collation)+'</td><td>'+escHtml(c.null)+'</td><td><strong>'+escHtml(c.key)+'</strong></td>'
|
||||
+'<td>'+(c.default??'NULL')+'</td><td>'+escHtml(c.extra)+'</td></tr>';
|
||||
});
|
||||
html+='</tbody></table></div>';
|
||||
panel.innerHTML=html;
|
||||
});
|
||||
}
|
||||
|
||||
function browseTable(db, table) {
|
||||
currentDb=db; currentTable=table;
|
||||
document.getElementById('tabBrowse').style.display='';
|
||||
document.getElementById('tabStructure').style.display='';
|
||||
document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById('tabBrowse').classList.add('active');
|
||||
showPanel('browse');
|
||||
loadBrowse(db,table,1);
|
||||
}
|
||||
|
||||
function loadBrowse(db,table,page) {
|
||||
const panel=document.getElementById('pmaBrowse');
|
||||
|
||||
const inputs=document.querySelectorAll('.pma-fi');
|
||||
let filters='';
|
||||
if(inputs.length){
|
||||
const p={};
|
||||
inputs.forEach(inp=>{if(inp.value.trim())p[inp.dataset.col]=inp.value.trim()});
|
||||
const s=new URLSearchParams(p).toString();
|
||||
if(s)filters='&'+s;
|
||||
}
|
||||
|
||||
panel.innerHTML='<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||||
|
||||
let body='action=browse&db='+encodeURIComponent(db)+'&table='+encodeURIComponent(table)+'&page='+page+filters+'&_csrf_token='+encodeURIComponent(getCsrfToken());
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(!d.success)return;
|
||||
const tp=Math.ceil(d.total/d.per_page);
|
||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:0.5rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-search"></i> <code>'+escHtml(table)+'</code></h2>'
|
||||
+'<span class="badge badge-info">'+d.total+' rows</span></div>';
|
||||
html+='<form onsubmit="event.preventDefault();loadBrowse(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\',1)">';
|
||||
html+='<div class="table-responsive" style="max-height:450px;overflow-y:auto"><table class="table table-sm"><thead><tr>';
|
||||
d.columns.forEach(c=>{html+='<th>'+escHtml(c)+'</th>'});
|
||||
html+='</tr><tr class="pma-filter-row">';
|
||||
d.columns.forEach(c=>{
|
||||
const cv=new URLSearchParams(filters.replace(/^&/,'')).get(c)||'';
|
||||
html+='<td><input type="text" class="form-input pma-fi" data-col="'+escHtml(c)+'" value="'+escHtml(cv)+'" placeholder="'+escHtml(c)+'" style="font-size:0.75rem;padding:0.2rem 0.35rem;width:100%;min-width:50px"></td>';
|
||||
});
|
||||
html+='</tr></thead><tbody>';
|
||||
d.rows.forEach(row=>{
|
||||
html+='<tr>';
|
||||
row.forEach(val=>{html+='<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis"><code>'+escHtml(String(val).substring(0,80))+'</code></td>'});
|
||||
html+='</tr>';
|
||||
});
|
||||
html+='</tbody></table></div>';
|
||||
html+='<div style="display:flex;gap:0.5rem;margin-top:0.5rem;align-items:center">';
|
||||
html+='<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-search"></i> Filter</button>';
|
||||
html+='<button type="button" class="btn btn-secondary btn-sm" onclick="clearBrowseFilters(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\')"><i class="fas fa-undo"></i> Clear</button>';
|
||||
if(tp>1){
|
||||
html+='<span style="flex:1"></span>';
|
||||
for(let p=1;p<=tp&&p<=10;p++)html+='<button type="button" class="btn btn-xs '+(p===page?'btn-primary':'btn-secondary')+'" onclick="loadBrowse(\''+db.replace(/'/g,"\\'")+"','"+table.replace(/'/g,"\\'")+'\','+p+')">'+p+'</button>';
|
||||
if(tp>10)html+='<span class="text-muted">...</span>';
|
||||
}
|
||||
html+='</div></form>';
|
||||
panel.innerHTML=html;
|
||||
});
|
||||
}
|
||||
|
||||
function clearBrowseFilters(db,table){
|
||||
document.querySelectorAll('.pma-fi').forEach(inp=>inp.value='');
|
||||
loadBrowse(db,table,1);
|
||||
}
|
||||
|
||||
function loadUsers() {
|
||||
showPanel('users');
|
||||
document.getElementById('tabUsers').style.display='';
|
||||
const panel=document.getElementById('pmaUsers');
|
||||
panel.innerHTML='<div style="text-align:center;padding:2rem;color:var(--text-muted)"><i class="fas fa-spinner fa-spin"></i> Loading users...</div>';
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=users&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-users"></i> Database Users</h2>'
|
||||
+'<span class="badge badge-info">'+(d.users?.length||0)+' users</span>'
|
||||
+'<span style="flex:1"></span>'
|
||||
+'<button class="btn btn-sm btn-success" onclick="showCreateUser()"><i class="fas fa-plus"></i> New User</button></div>';
|
||||
html+='<div class="table-responsive"><table class="table table-sm"><thead><tr><th>User</th><th>Host</th><th>Databases</th><th>Expired</th><th>Actions</th></tr></thead><tbody>';
|
||||
(d.users||[]).forEach(u=>{
|
||||
const dbList=(d.grants||[]).filter(g=>g.user===u.user&&g.host===u.host).map(g=>g.db).join(', ');
|
||||
html+='<tr><td><strong>'+escHtml(u.user)+'</strong></td><td><code>'+escHtml(u.host)+'</code></td><td>'+(dbList||'<span class="text-muted">-</span>')+'</td><td>'+escHtml(u.expired)+'</td>'
|
||||
+'<td class="actions-cell">'
|
||||
+'<button class="btn btn-xs btn-secondary" onclick="showEditUser(\''+escHtml(u.user)+"','"+escHtml(u.host)+'\')" title="Change password"><i class="fas fa-key"></i></button> '
|
||||
+'<button class="btn btn-xs btn-danger" onclick="deleteUser(\''+escHtml(u.user)+"','"+escHtml(u.host)+'\')" title="Delete"><i class="fas fa-trash"></i></button>'
|
||||
+'</td></tr>';
|
||||
});
|
||||
html+='</tbody></table></div>';
|
||||
panel.innerHTML=html;
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateUser() {
|
||||
const panel=document.getElementById('pmaUsers');
|
||||
let html='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-user-plus"></i> Create User</h2>'
|
||||
+'<span style="flex:1"></span>'
|
||||
+'<button class="btn btn-sm btn-secondary" onclick="loadUsers()"><i class="fas fa-arrow-left"></i> Back</button></div>'
|
||||
+'<div class="dashboard-grid"><div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-user"></i> Account</h2></div><div class="card-body">'
|
||||
+'<div class="form-row"><div class="form-group"><label>Username</label><input type="text" id="nuUser" class="form-input" placeholder="username"></div>'
|
||||
+'<div class="form-group"><label>Password</label><input type="password" id="nuPass" class="form-input" placeholder="password"></div>'
|
||||
+'<div class="form-group"><label>Host</label><input type="text" id="nuHost" class="form-input" value="localhost" placeholder="localhost"></div></div></div></div>'
|
||||
+'<div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-database"></i> Database Access</h2></div><div class="card-body">'
|
||||
+'<div style="margin-bottom:0.75rem"><label class="checkbox-label"><input type="checkbox" id="nuAllDb" onchange="document.querySelectorAll(\'.nuDbCheck\').forEach(c=>c.checked=this.checked)"> <strong>Select all</strong></label></div>';
|
||||
|
||||
<?php foreach ($databases as $d): ?>
|
||||
var dbname = '<?= htmlspecialchars($d['name']) ?>';
|
||||
if (dbname !== 'information_schema' && dbname !== 'performance_schema' && dbname !== 'mysql' && dbname !== 'sys') {
|
||||
html+='<label class="checkbox-label" style="margin-top:0.25rem"><input type="checkbox" class="nuDbCheck" value="'+dbname+'"> <span>'+dbname+'</span></label>';
|
||||
}
|
||||
<?php endforeach; ?>
|
||||
|
||||
html+='</div></div></div>'
|
||||
+'<div class="dashboard-card"><div class="card-header"><h2><i class="fas fa-lock"></i> Privileges</h2></div><div class="card-body">'
|
||||
+'<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:0.25rem">';
|
||||
['ALL PRIVILEGES','SELECT','INSERT','UPDATE','DELETE','CREATE','DROP','ALTER','INDEX','CREATE VIEW','SHOW VIEW','CREATE ROUTINE','ALTER ROUTINE','EXECUTE','LOCK TABLES','REFERENCES','EVENT','TRIGGER'].forEach(function(p){
|
||||
var checked = p === 'ALL PRIVILEGES' ? ' checked' : '';
|
||||
html+='<label class="checkbox-label" style="font-size:0.82rem"><input type="checkbox" class="nuPriv" value="'+p+'"'+checked+'> <span>'+p+'</span></label>';
|
||||
});
|
||||
html+='</div></div></div>'
|
||||
+'<div style="margin-top:1rem;display:flex;gap:0.5rem">'
|
||||
+'<button class="btn btn-primary" onclick="doCreateUser()"><i class="fas fa-save"></i> Create User</button>'
|
||||
+'<button class="btn btn-secondary" onclick="loadUsers()">Cancel</button></div>';
|
||||
panel.innerHTML=html;
|
||||
}
|
||||
|
||||
function doCreateUser() {
|
||||
const user=document.getElementById('nuUser').value;
|
||||
const pass=document.getElementById('nuPass').value;
|
||||
const host=document.getElementById('nuHost').value||'localhost';
|
||||
if(!user||!pass){showToast('error','Username and password required');return;}
|
||||
|
||||
const privs=[];
|
||||
document.querySelectorAll('.nuPriv:checked').forEach(cb=>privs.push(cb.value));
|
||||
const grantPrivs = privs.includes('ALL PRIVILEGES') ? 'ALL PRIVILEGES' : privs.join(',');
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body: 'action=create_user&new_user='+encodeURIComponent(user)+'&new_pass='+encodeURIComponent(pass)+'&new_host='+encodeURIComponent(host)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
showToast(d.success?'success':'error',d.message);
|
||||
if(d.success){
|
||||
const dbs=[];
|
||||
document.querySelectorAll('.nuDbCheck:checked').forEach(cb=>dbs.push(cb.value));
|
||||
if(dbs.length>0){
|
||||
let cnt=0;
|
||||
dbs.forEach(db=>{
|
||||
fetch('/servers/<?= $server['id'] ?>/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=grant&grant_user='+encodeURIComponent(user)+'&grant_db='+encodeURIComponent(db)+'&grant_host='+encodeURIComponent(host)+'&grant_privs='+encodeURIComponent(grantPrivs)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r2=>r2.json()).then(g=>{
|
||||
cnt++;
|
||||
if(cnt===dbs.length)loadUsers();
|
||||
});
|
||||
});
|
||||
}else{loadUsers();}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showEditUser(user, host) {
|
||||
const panel = document.getElementById('pmaUsers');
|
||||
panel.innerHTML='<div style="display:flex;gap:0.75rem;align-items:center;margin-bottom:1rem">'
|
||||
+'<h2 style="margin:0;font-size:1rem"><i class="fas fa-key"></i> Change Password</h2>'
|
||||
+'<code>'+escHtml(user)+'</code> @ <code>'+escHtml(host)+'</code>'
|
||||
+'<span style="flex:1"></span>'
|
||||
+'<button class="btn btn-sm btn-secondary" onclick="loadUsers()"><i class="fas fa-arrow-left"></i> Back</button></div>'
|
||||
+'<div class="card"><div class="card-body">'
|
||||
+'<div class="form-group"><label>New Password</label><input type="password" id="epPass" class="form-input" placeholder="new password"></div>'
|
||||
+'<div style="margin-top:1rem;display:flex;gap:0.5rem">'
|
||||
+'<button class="btn btn-primary" onclick="doChangePass(\''+escHtml(user)+"','"+escHtml(host)+'\')"><i class="fas fa-save"></i> Save</button>'
|
||||
+'<button class="btn btn-secondary" onclick="loadUsers()">Cancel</button></div></div></div>';
|
||||
}
|
||||
|
||||
function doChangePass(user, host) {
|
||||
const pass = document.getElementById('epPass').value;
|
||||
if (!pass) { showToast('error','Password required'); return; }
|
||||
fetch('/servers/<?= $server['id'] ?>/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action=change_pass&target_user='+encodeURIComponent(user)+'&target_host='+encodeURIComponent(host)+'&new_pass='+encodeURIComponent(pass)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)loadUsers()});
|
||||
}
|
||||
|
||||
function deleteUser(user, host) {
|
||||
if (!confirm('Delete user \''+user+'\'@\''+host+'\'?\nThis cannot be undone.')) return;
|
||||
fetch('/servers/<?= $server['id'] ?>/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action=drop_user&target_user='+encodeURIComponent(user)+'&target_host='+encodeURIComponent(host)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)loadUsers()});
|
||||
}
|
||||
|
||||
function runSql() {
|
||||
const sql=document.getElementById('sqlInput').value; if(!sql)return;
|
||||
const rd=document.getElementById('sqlResult'); const rt=document.getElementById('sqlResultText');
|
||||
rd.style.display='none';
|
||||
fetch('/servers/<?= $server['id'] ?>/database',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},body:'action=query&query='+encodeURIComponent(sql)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{if(d.output){rt.textContent=d.output;rd.style.display='block'}else showToast('error',d.message||'No results')});
|
||||
}
|
||||
|
||||
function showPanel(id) {
|
||||
document.querySelectorAll('.pma-panel').forEach(p=>p.classList.remove('active'));
|
||||
document.getElementById('pma'+id.charAt(0).toUpperCase()+id.slice(1)).classList.add('active');
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.pma-tab').forEach(t=>t.classList.remove('active'));
|
||||
const map={browse:'pmaBrowse',structure:'pmaStructure',sql:'pmaSql',users:'pmaUsers'};
|
||||
const el=document.getElementById('tab'+tab.charAt(0).toUpperCase()+tab.slice(1));
|
||||
if(el)el.classList.add('active');
|
||||
showPanel(tab);
|
||||
if(tab==='sql'){document.getElementById('pmaWelcome').classList.remove('active');document.getElementById('pmaSql').classList.add('active');}
|
||||
}
|
||||
|
||||
document.getElementById('sqlInput').addEventListener('keydown',function(e){if((e.ctrlKey||e.metaKey)&&e.key==='Enter')runSql()});
|
||||
|
||||
function escHtml(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
||||
const serverId = <?= json_encode($server['id']) ?>;
|
||||
const databases = <?= json_encode(array_column($databases, 'name')) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/database-manager.js"></script>
|
||||
<?php endif; ?>
|
||||
|
||||
42
views/servers/edit.php
Executable file → Normal file
42
views/servers/edit.php
Executable file → Normal file
@@ -155,45 +155,5 @@ $hasKey = !empty($server['ssh_key']);
|
||||
|
||||
<script>
|
||||
let permIndex = <?= count($permissions) ?>;
|
||||
|
||||
function createPermissionRow(type, value, description) {
|
||||
const idx = permIndex++;
|
||||
const container = document.getElementById('permissions-container');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'permission-row';
|
||||
div.dataset.index = idx;
|
||||
div.innerHTML = `
|
||||
<select name="permissions[${idx}][type]" class="form-input permission-type">
|
||||
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
|
||||
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
|
||||
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
|
||||
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
|
||||
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
|
||||
</select>
|
||||
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
|
||||
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
|
||||
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
|
||||
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
function removePermissionRow(btn) {
|
||||
btn.closest('.permission-row').remove();
|
||||
}
|
||||
|
||||
function addPresetPermission(type, value, description) {
|
||||
createPermissionRow(type, value, description);
|
||||
}
|
||||
|
||||
document.getElementById('addPermissionBtn').addEventListener('click', function() {
|
||||
createPermissionRow('command', '', '');
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/server-permissions.js"></script>
|
||||
|
||||
33
views/servers/index.php
Executable file → Normal file
33
views/servers/index.php
Executable file → Normal file
@@ -141,35 +141,4 @@ $groups = $groups ?? [];
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function deleteServer(id, name) {
|
||||
if (confirm('Are you sure you want to delete server "' + name + '"?\nThis action cannot be undone.')) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/servers/' + id + '/delete';
|
||||
const csrf = document.createElement('input');
|
||||
csrf.type = 'hidden';
|
||||
csrf.name = '_csrf_token';
|
||||
csrf.value = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
form.appendChild(csrf);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('serverSearch')?.addEventListener('input', function(e) {
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
document.getElementById('statusFilter')?.addEventListener('change', applyFilters);
|
||||
document.getElementById('groupFilter')?.addEventListener('change', applyFilters);
|
||||
|
||||
function applyFilters() {
|
||||
const search = document.getElementById('serverSearch')?.value || '';
|
||||
const status = document.getElementById('statusFilter')?.value || '';
|
||||
const group = document.getElementById('groupFilter')?.value || '';
|
||||
window.location.href = '/servers?search=' + encodeURIComponent(search) +
|
||||
'&status=' + encodeURIComponent(status) +
|
||||
'&group=' + encodeURIComponent(group);
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/server-list.js"></script>
|
||||
|
||||
0
views/servers/logs.php
Executable file → Normal file
0
views/servers/logs.php
Executable file → Normal file
@@ -321,326 +321,7 @@ $configValid = $nginx['config_valid'] ?? false;
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentEditFile = null;
|
||||
let editorDirty = false;
|
||||
let editorUndoStack = [];
|
||||
let editorRedoStack = [];
|
||||
let editorHistoryIndex = -1;
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function updateLineNumbers() {
|
||||
const ta = document.getElementById('editorContent');
|
||||
const gutter = document.getElementById('editorGutter');
|
||||
const lines = ta.value.split('\n').length;
|
||||
const numbers = [];
|
||||
for (let i = 1; i <= lines; i++) {
|
||||
numbers.push('<span>' + i + '</span>');
|
||||
}
|
||||
gutter.innerHTML = numbers.join('');
|
||||
}
|
||||
|
||||
function updateCursorPos() {
|
||||
const ta = document.getElementById('editorContent');
|
||||
const before = ta.value.substring(0, ta.selectionStart);
|
||||
const lines = before.split('\n');
|
||||
const line = lines.length;
|
||||
const col = lines[lines.length - 1].length + 1;
|
||||
document.getElementById('editorCursorPos').textContent = 'Ln ' + line + ', Col ' + col;
|
||||
}
|
||||
|
||||
function highlightNginx(text) {
|
||||
let h = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/#(.*)$/gm, '<span class="hl-comment">#$1</span>')
|
||||
.replace(/\b(server|location|listen|server_name|root|index|try_files|include|fastcgi_pass|proxy_pass|return|rewrite|set|if|error_page|ssl_certificate|ssl_certificate_key|ssl_protocols|ssl_ciphers|add_header|access_log|error_log|keepalive|gzip|proxy_set_header|proxy_redirect|client_max_body_size|autoindex)\b/g, '<span class="hl-keyword">$1</span>')
|
||||
.replace(/\b(\d+)\b/g, '<span class="hl-number">$1</span>')
|
||||
.replace(/\b(https?|fastcgi|unix):\/\/[^\s;{]+/g, '<span class="hl-string">$&</span>')
|
||||
.replace(/'(.*?)'/g, '<span class="hl-string">\'$1\'</span>')
|
||||
.replace(/(\$\w+)/g, '<span class="hl-var">$1</span>')
|
||||
.replace(/\b(\w+\.\w+(?:\.\w+)*)\b/g, function(m) {
|
||||
if (m.includes('com') || m.includes('org') || m.includes('net') || m.includes('io') || m.includes('local')) {
|
||||
return '<span class="hl-string">' + m + '</span>';
|
||||
}
|
||||
return m;
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
function syncHighlight() {
|
||||
const ta = document.getElementById('editorContent');
|
||||
const code = document.getElementById('editorHighlightCode');
|
||||
code.innerHTML = highlightNginx(ta.value) + '\n';
|
||||
}
|
||||
|
||||
function pushHistory() {
|
||||
const ta = document.getElementById('editorContent');
|
||||
if (editorHistoryIndex >= 0 && editorUndoStack[editorHistoryIndex] === ta.value) return;
|
||||
editorUndoStack = editorUndoStack.slice(0, editorHistoryIndex + 1);
|
||||
editorUndoStack.push(ta.value);
|
||||
editorRedoStack = [];
|
||||
editorHistoryIndex = editorUndoStack.length - 1;
|
||||
if (editorUndoStack.length > 100) {
|
||||
editorUndoStack.shift();
|
||||
editorHistoryIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
function undoEditor() {
|
||||
if (editorHistoryIndex <= 0) return;
|
||||
editorRedoStack.push(editorUndoStack[editorHistoryIndex]);
|
||||
editorHistoryIndex--;
|
||||
const ta = document.getElementById('editorContent');
|
||||
ta.value = editorUndoStack[editorHistoryIndex];
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
function redoEditor() {
|
||||
if (editorRedoStack.length === 0) return;
|
||||
const val = editorRedoStack.pop();
|
||||
editorUndoStack.push(val);
|
||||
editorHistoryIndex++;
|
||||
const ta = document.getElementById('editorContent');
|
||||
ta.value = val;
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
}
|
||||
|
||||
function nginxAction(action) {
|
||||
const btn = event.target.closest('button');
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Working...';
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/nginx', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'action=' + action + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.output) {
|
||||
document.getElementById('nginxResultMessage').textContent = data.message;
|
||||
document.getElementById('nginxResultOutputText').textContent = data.output;
|
||||
document.getElementById('nginxResultOutput').style.display = 'block';
|
||||
document.getElementById('nginxResultModal').style.display = 'flex';
|
||||
}
|
||||
if (data.success && ['restart', 'reload', 'start', 'stop'].includes(action)) {
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message))
|
||||
.finally(() => { btn.disabled = false; btn.innerHTML = origText; });
|
||||
}
|
||||
|
||||
function editFile(file) {
|
||||
currentEditFile = file;
|
||||
editorUndoStack = [];
|
||||
editorRedoStack = [];
|
||||
editorHistoryIndex = -1;
|
||||
editorDirty = false;
|
||||
document.getElementById('editorFilePath').textContent = file;
|
||||
document.getElementById('editorContent').value = 'Loading...';
|
||||
document.getElementById('editorStatus').textContent = 'Loading...';
|
||||
document.getElementById('editorDirtyIndicator').style.display = 'none';
|
||||
document.getElementById('editorFileSize').textContent = '';
|
||||
document.getElementById('nginxEditorModal').style.display = 'flex';
|
||||
setTimeout(() => document.getElementById('editorContent').focus(), 100);
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/nginx/file?file=' + encodeURIComponent(file))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const ta = document.getElementById('editorContent');
|
||||
ta.value = data.content;
|
||||
document.getElementById('editorFileSize').textContent = (data.content.length / 1024).toFixed(1) + ' KB';
|
||||
document.getElementById('editorStatus').textContent = 'Ready';
|
||||
document.getElementById('editorDirtyIndicator').style.display = 'none';
|
||||
editorDirty = false;
|
||||
updateLineNumbers();
|
||||
syncHighlight();
|
||||
pushHistory();
|
||||
} else {
|
||||
showToast('error', data.message);
|
||||
closeEditor();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('error', 'Failed to load file');
|
||||
closeEditor();
|
||||
});
|
||||
|
||||
const ta = document.getElementById('editorContent');
|
||||
ta.oninput = function() {
|
||||
editorDirty = true;
|
||||
document.getElementById('editorStatus').textContent = 'Unsaved changes';
|
||||
document.getElementById('editorDirtyIndicator').style.display = 'inline';
|
||||
updateLineNumbers();
|
||||
syncHighlight();
|
||||
pushHistory();
|
||||
};
|
||||
ta.onscroll = function() {
|
||||
document.getElementById('editorGutter').scrollTop = this.scrollTop;
|
||||
document.getElementById('editorHighlight').scrollTop = this.scrollTop;
|
||||
};
|
||||
ta.onkeydown = function(e) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + 4;
|
||||
this.dispatchEvent(new Event('input'));
|
||||
}
|
||||
};
|
||||
ta.onmouseup = ta.onkeyup = function() {
|
||||
updateCursorPos();
|
||||
};
|
||||
ta.addEventListener('paste', function() {
|
||||
setTimeout(() => {
|
||||
this.dispatchEvent(new Event('input'));
|
||||
updateCursorPos();
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function saveFile() {
|
||||
const content = document.getElementById('editorContent').value;
|
||||
document.getElementById('editorStatus').textContent = 'Saving...';
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/nginx/file', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'file=' + encodeURIComponent(currentEditFile) + '&content=' + encodeURIComponent(content) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) {
|
||||
editorDirty = false;
|
||||
document.getElementById('editorStatus').textContent = 'Saved';
|
||||
document.getElementById('editorDirtyIndicator').style.display = 'none';
|
||||
closeEditor();
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
document.getElementById('editorStatus').textContent = 'Save failed';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('error', 'Save request failed');
|
||||
document.getElementById('editorStatus').textContent = 'Error';
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSite(site) {
|
||||
if (!confirm("Permanently delete site '" + site + "'?\nThis will remove both the config file and site directory contents.")) return;
|
||||
if (!confirm("Are you sure? This cannot be undone.")) return;
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/nginx/site', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_method=DELETE&site=' + encodeURIComponent(site) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) setTimeout(() => location.reload(), 500);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function toggleSite(site, enable) {
|
||||
const action = enable ? 'enable' : 'disable';
|
||||
if (!confirm((enable ? 'Enable' : 'Disable') + " site '" + site + "'?")) return;
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/nginx/toggle-site', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'site=' + encodeURIComponent(site) + '&enable=' + (enable ? '1' : '0') + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) setTimeout(() => location.reload(), 500);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (editorDirty && !confirm('Discard unsaved changes?')) return;
|
||||
document.getElementById('nginxEditorModal').style.display = 'none';
|
||||
currentEditFile = null;
|
||||
editorDirty = false;
|
||||
}
|
||||
|
||||
function closeNginxModal() {
|
||||
document.getElementById('nginxResultModal').style.display = 'none';
|
||||
document.getElementById('nginxResultOutput').style.display = 'none';
|
||||
}
|
||||
|
||||
function openNewSiteModal() {
|
||||
document.getElementById('newSiteForm').reset();
|
||||
document.getElementById('siteServerName').value = '';
|
||||
document.getElementById('sitePhp').checked = true;
|
||||
document.getElementById('siteEnable').checked = true;
|
||||
document.getElementById('nginxNewSiteModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeNewSiteModal() {
|
||||
document.getElementById('nginxNewSiteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function createSite(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('createSiteBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
|
||||
|
||||
const formData = new URLSearchParams(new FormData(document.getElementById('newSiteForm')));
|
||||
formData.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/nginx/site', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: formData.toString(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) {
|
||||
closeNewSiteModal();
|
||||
setTimeout(() => location.reload(), 800);
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message))
|
||||
.finally(() => { btn.disabled = false; btn.innerHTML = '<i class="fas fa-plus"></i> Create Site'; });
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') { closeEditor(); closeNginxModal(); }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && currentEditFile) {
|
||||
e.preventDefault();
|
||||
saveFile();
|
||||
}
|
||||
});
|
||||
const serverId = <?= json_encode($server['id']) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/nginx-manager.js"></script>
|
||||
<?php endif; ?>
|
||||
|
||||
64
views/servers/processes.php
Executable file → Normal file
64
views/servers/processes.php
Executable file → Normal file
@@ -101,66 +101,6 @@ $processList = $processList ?? [];
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let pendingPid = null;
|
||||
let pendingBtn = null;
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function killProcess(pid, btn) {
|
||||
pendingPid = pid;
|
||||
pendingBtn = btn;
|
||||
document.getElementById('killPidDisplay').textContent = pid;
|
||||
document.getElementById('killModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeKillModal() {
|
||||
pendingPid = null;
|
||||
pendingBtn = null;
|
||||
document.getElementById('killForce').checked = false;
|
||||
document.getElementById('killModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function confirmKill() {
|
||||
if (!pendingPid) return;
|
||||
|
||||
const signal = document.getElementById('killForce').checked ? 9 : 15;
|
||||
const btn = document.getElementById('confirmKillBtn');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Killing...';
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/processes/kill', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'pid=' + pendingPid + '&signal=' + signal + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success && pendingBtn) {
|
||||
const row = pendingBtn.closest('tr');
|
||||
row.style.opacity = '0.4';
|
||||
pendingBtn.disabled = true;
|
||||
pendingBtn.innerHTML = '<i class="fas fa-check"></i> Killed';
|
||||
}
|
||||
closeKillModal();
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('error', 'Request failed: ' + err.message);
|
||||
closeKillModal();
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeKillModal();
|
||||
});
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
</script>
|
||||
<script src="/assets/js/server-processes.js"></script>
|
||||
|
||||
@@ -49,92 +49,7 @@ $totalPages = max(1, ceil(count($services) / $perPage));
|
||||
<script>
|
||||
const allServices = <?= json_encode($services, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
|
||||
const perPage = <?= $perPage ?>;
|
||||
|
||||
function applyFilters() {
|
||||
const nameQ = document.getElementById('filterName').value.toLowerCase();
|
||||
const statusQ = document.getElementById('filterStatus').value;
|
||||
const filtered = allServices.filter(s => {
|
||||
if (nameQ && !s.name.toLowerCase().includes(nameQ)) return false;
|
||||
if (statusQ && s.status !== statusQ) return false;
|
||||
return true;
|
||||
});
|
||||
renderPage(filtered, 1);
|
||||
}
|
||||
|
||||
function renderPage(list, page) {
|
||||
const totalPages = Math.ceil(list.length / perPage) || 1;
|
||||
const start = (page - 1) * perPage;
|
||||
const slice = list.slice(start, start + perPage);
|
||||
const tbody = document.getElementById('servicesBody');
|
||||
let html = '';
|
||||
slice.forEach(s => {
|
||||
const isActive = s.status === 'active';
|
||||
const isInactive = s.status === 'inactive';
|
||||
html += '<tr class="service-row">';
|
||||
html += '<td><code>' + escHtml(s.name) + '</code></td>';
|
||||
html += '<td><span class="badge ' + (isActive ? 'badge-success' : isInactive ? 'badge-secondary' : 'badge-warning') + '">' + s.status + '</span></td>';
|
||||
html += '<td class="actions-cell" style="white-space:nowrap">';
|
||||
if (!isActive) html += '<button class="btn btn-xs btn-success" onclick="serviceAction(\'start\',\'' + escHtml(s.name) + '\')" title="Start"><i class="fas fa-play"></i></button> ';
|
||||
if (!isInactive) html += '<button class="btn btn-xs btn-danger" onclick="serviceAction(\'stop\',\'' + escHtml(s.name) + '\')" title="Stop"><i class="fas fa-stop"></i></button> ';
|
||||
html += '<button class="btn btn-xs btn-warning" onclick="serviceAction(\'restart\',\'' + escHtml(s.name) + '\')" title="Restart"><i class="fas fa-redo"></i></button> ';
|
||||
html += '<button class="btn btn-xs btn-info" onclick="serviceAction(\'reload\',\'' + escHtml(s.name) + '\')" title="Reload"><i class="fas fa-sync-alt"></i></button> ';
|
||||
if (!s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'enable\',\'' + escHtml(s.name) + '\')" title="Enable"><i class="fas fa-power-off"></i></button> ';
|
||||
if (s.enabled) html += '<button class="btn btn-xs btn-secondary" onclick="serviceAction(\'disable\',\'' + escHtml(s.name) + '\')" title="Disable"><i class="fas fa-ban"></i></button> ';
|
||||
html += '</td></tr>';
|
||||
});
|
||||
if (!html) html = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:2rem">No services match the filter.</td></tr>';
|
||||
tbody.innerHTML = html;
|
||||
renderPagination(list, page);
|
||||
}
|
||||
|
||||
function renderPagination(list, page) {
|
||||
const totalPages = Math.ceil(list.length / perPage) || 1;
|
||||
const container = document.getElementById('servicesPagination');
|
||||
let html = '';
|
||||
for (let p = 1; p <= totalPages && p <= 15; p++) {
|
||||
html += '<button class="btn btn-xs ' + (p === page ? 'btn-primary' : 'btn-secondary') + '" onclick="renderPage(list,' + p + ')" data-list=\'' + JSON.stringify(list).replace(/'/g,"'") + '\'>' + p + '</button>';
|
||||
}
|
||||
if (totalPages > 15) html += '<span class="text-muted" style="font-size:0.8rem">...</span>';
|
||||
container.innerHTML = html + ' <span class="text-muted" style="font-size:0.8rem">' + list.length + ' services</span>';
|
||||
// Fix pagination onclick by rebinding
|
||||
container.querySelectorAll('button').forEach(btn => {
|
||||
const p = parseInt(btn.textContent);
|
||||
if (!isNaN(p)) {
|
||||
btn.onclick = function() { renderPage(list, p); };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters();
|
||||
|
||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
||||
|
||||
function serviceAction(action, service) {
|
||||
fetch('/servers/<?= $server['id'] ?>/services', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()},
|
||||
body:'action='+action+'&service='+encodeURIComponent(service)+'&_csrf_token='+encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
showToast(d.success?'success':'error', d.message);
|
||||
if (d.output) {
|
||||
document.getElementById('srTitle').textContent = d.success ? 'Success' : 'Error';
|
||||
document.getElementById('srMessage').textContent = d.message;
|
||||
document.getElementById('srOutputText').textContent = d.output;
|
||||
document.getElementById('srOutput').style.display = 'block';
|
||||
document.getElementById('serviceResultModal').style.display = 'flex';
|
||||
}
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed'));
|
||||
}
|
||||
|
||||
function closeSrModal() {
|
||||
document.getElementById('serviceResultModal').style.display = 'none';
|
||||
document.getElementById('srOutput').style.display = 'none';
|
||||
}
|
||||
|
||||
function escHtml(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
|
||||
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeSrModal(); });
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
</script>
|
||||
<script src="/assets/js/server-services.js"></script>
|
||||
|
||||
|
||||
329
views/servers/show.php
Executable file → Normal file
329
views/servers/show.php
Executable file → Normal file
@@ -370,330 +370,9 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
|
||||
</form>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script>const historicalData = <?= json_encode($historicalMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;</script>
|
||||
<script>
|
||||
const historicalData = <?= json_encode($historicalMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
|
||||
|
||||
let metricsChart = null;
|
||||
|
||||
function initChart(data) {
|
||||
const container = document.getElementById('metricsChart').parentNode;
|
||||
const oldCanvas = document.getElementById('metricsChart');
|
||||
const newCanvas = document.createElement('canvas');
|
||||
newCanvas.id = 'metricsChart';
|
||||
newCanvas.height = 300;
|
||||
container.replaceChild(newCanvas, oldCanvas);
|
||||
const ctx = newCanvas.getContext('2d');
|
||||
const labels = data.map(m => {
|
||||
const d = new Date(m.created_at);
|
||||
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
|
||||
});
|
||||
|
||||
metricsChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU',
|
||||
data: data.map(m => m.cpu_usage),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
{
|
||||
label: 'RAM',
|
||||
data: data.map(m => m.ram_usage),
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
{
|
||||
label: 'Disk',
|
||||
data: data.map(m => m.disk_usage),
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#9ca3af',
|
||||
font: { family: "'Inter', sans-serif" },
|
||||
boxWidth: 12,
|
||||
padding: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#1a1d2b',
|
||||
borderColor: '#2a2d3d',
|
||||
borderWidth: 1,
|
||||
titleColor: '#e1e4ed',
|
||||
bodyColor: '#9ca3af',
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
maxTicksLimit: 12,
|
||||
font: { size: 11 },
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
font: { size: 11 },
|
||||
callback: function(v) { return v + '%'; },
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (historicalData.length > 0) {
|
||||
initChart(historicalData);
|
||||
}
|
||||
|
||||
let pendingAgentAction = null;
|
||||
|
||||
function showAgentModal(action) {
|
||||
pendingAgentAction = action;
|
||||
const modal = document.getElementById('agentConfirmModal');
|
||||
const title = document.getElementById('agentModalTitle');
|
||||
const msg = document.getElementById('agentModalMessage');
|
||||
const btn = document.getElementById('agentModalConfirmBtn');
|
||||
|
||||
if (action === 'install') {
|
||||
title.textContent = 'Install Monitoring Agent';
|
||||
msg.innerHTML = 'This will install the monitoring agent on <strong><?= htmlspecialchars($server['name'] ?? '') ?></strong> via SSH.';
|
||||
btn.textContent = 'Install';
|
||||
btn.className = 'btn btn-primary';
|
||||
} else {
|
||||
title.textContent = 'Uninstall Monitoring Agent';
|
||||
msg.innerHTML = 'This will uninstall the monitoring agent from <strong><?= htmlspecialchars($server['name'] ?? '') ?></strong> via SSH. The service will be stopped and all agent files removed.';
|
||||
btn.textContent = 'Uninstall';
|
||||
btn.className = 'btn btn-danger';
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
modal.onclick = function(e) {
|
||||
if (e.target === modal) closeAgentModal();
|
||||
};
|
||||
}
|
||||
|
||||
function closeAgentModal() {
|
||||
document.getElementById('agentConfirmModal').style.display = 'none';
|
||||
pendingAgentAction = null;
|
||||
}
|
||||
|
||||
function confirmAgentAction() {
|
||||
if (!pendingAgentAction) return;
|
||||
|
||||
const action = pendingAgentAction;
|
||||
const btn = document.getElementById('agentModalConfirmBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = action === 'install' ? 'Installing...' : 'Uninstalling...';
|
||||
|
||||
closeAgentModal();
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/agent/' + action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', action === 'install' ? 'Agent installed successfully.' : 'Agent uninstalled successfully.');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
let msg = data.error || (action === 'install' ? 'Installation failed.' : 'Uninstallation failed.');
|
||||
if (data.output) msg += ' Output: ' + data.output.replace(/\n/g, ' | ');
|
||||
showToast('error', msg);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('error', 'Request failed: ' + err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = action === 'install' ? 'Install' : 'Uninstall';
|
||||
});
|
||||
}
|
||||
|
||||
function copyAgentKey() {
|
||||
const key = document.getElementById('agentKeyFull').value;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
showToast('success', 'Agent key copied to clipboard.');
|
||||
}).catch(() => {
|
||||
showToast('error', 'Failed to copy agent key.');
|
||||
});
|
||||
}
|
||||
|
||||
function regenerateAgentKey() {
|
||||
if (!confirm('Regenerate agent key? The current key will stop working immediately.')) return;
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/agent/regenerate-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', 'Agent key regenerated.');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast('error', data.error || 'Failed to regenerate key.');
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function serverAction(action) {
|
||||
if (action === 'shutdown' || action === 'reboot') {
|
||||
if (!confirm('Are you sure you want to ' + action + ' this server?')) return;
|
||||
}
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/' + action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function restartService() {
|
||||
const service = prompt('Enter service name to restart (e.g., nginx, mysql, apache2):');
|
||||
if (!service) return;
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/restart-service', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'service=' + encodeURIComponent(service) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function refreshMetrics() {
|
||||
fetch('/servers/<?= $server['id'] ?>/metrics')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.data) {
|
||||
const m = data.data;
|
||||
document.getElementById('cpuMetric').textContent = (m.cpu_usage || '-') + '%';
|
||||
document.getElementById('ramMetric').textContent = (m.ram_usage || '-') + '%';
|
||||
document.getElementById('diskMetric').textContent = (m.disk_usage || '-') + '%';
|
||||
document.getElementById('loadMetric').textContent = m.load_average || '-';
|
||||
document.getElementById('uptimeMetric').textContent = m.uptime || '-';
|
||||
if (metricsChart && metricsChart.data.labels.length > 0) {
|
||||
const now = new Date();
|
||||
const label = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
|
||||
metricsChart.data.labels.push(label);
|
||||
metricsChart.data.datasets[0].data.push(m.cpu_usage);
|
||||
metricsChart.data.datasets[1].data.push(m.ram_usage);
|
||||
metricsChart.data.datasets[2].data.push(m.disk_usage);
|
||||
if (metricsChart.data.labels.length > 48) {
|
||||
metricsChart.data.labels.shift();
|
||||
metricsChart.data.datasets.forEach(ds => ds.data.shift());
|
||||
}
|
||||
metricsChart.update('none');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(refreshMetrics, 30000);
|
||||
|
||||
function showThresholdModal() {
|
||||
document.getElementById('thresholdModal').style.display = 'flex';
|
||||
document.getElementById('thresholdModal').onclick = function(e) {
|
||||
if (e.target === this) closeThresholdModal();
|
||||
};
|
||||
}
|
||||
|
||||
function closeThresholdModal() {
|
||||
document.getElementById('thresholdModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveThresholds() {
|
||||
const form = document.getElementById('thresholdForm');
|
||||
const data = new URLSearchParams(new FormData(form));
|
||||
data.append('_csrf_token', getCsrfToken());
|
||||
|
||||
document.querySelector('#thresholdModal .btn-primary').disabled = true;
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/thresholds', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: data.toString(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
showToast(d.success ? 'success' : 'error', d.message ?? d.error);
|
||||
if (d.success) closeThresholdModal();
|
||||
})
|
||||
.catch(e => showToast('error', 'Request failed: ' + e.message))
|
||||
.finally(() => {
|
||||
document.querySelector('#thresholdModal .btn-primary').disabled = false;
|
||||
});
|
||||
}
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
const serverName = '<?= addslashes($server['name'] ?? '') ?>';
|
||||
</script>
|
||||
<script src="/assets/js/server-show.js"></script>
|
||||
|
||||
@@ -189,77 +189,6 @@ $certs = $ssl['certificates'] ?? [];
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function sslAction(action, extraData) {
|
||||
const data = 'action=' + action + '&_csrf_token=' + encodeURIComponent(getCsrfToken());
|
||||
const body = extraData ? data + '&' + extraData : data;
|
||||
|
||||
fetch('/servers/<?= $server['id'] ?>/ssl', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: body,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.output) {
|
||||
document.getElementById('sslResultMessage').textContent = data.message || '';
|
||||
document.getElementById('sslResultOutputText').textContent = data.output;
|
||||
document.getElementById('sslResultOutput').style.display = 'block';
|
||||
document.getElementById('sslResultModal').style.display = 'flex';
|
||||
}
|
||||
if (action === 'install' && data.success) {
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
}
|
||||
if (['renew', 'delete'].includes(action) && data.success) {
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
})
|
||||
.catch(err => showToast('error', 'Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
function openRequestModal() {
|
||||
document.getElementById('sslRequestForm').reset();
|
||||
document.getElementById('sslRequestModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeRequestModal() {
|
||||
document.getElementById('sslRequestModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function requestCert(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('requestCertBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Requesting...';
|
||||
|
||||
const domains = document.getElementById('sslDomains').value;
|
||||
const email = document.getElementById('sslEmail').value;
|
||||
|
||||
sslAction('request', 'domains=' + encodeURIComponent(domains) + '&email=' + encodeURIComponent(email));
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-plus"></i> Request Certificate';
|
||||
closeRequestModal();
|
||||
}
|
||||
|
||||
function deleteCert(name) {
|
||||
if (!confirm("Delete certificate '" + name + "'?")) return;
|
||||
sslAction('delete', 'cert_name=' + encodeURIComponent(name));
|
||||
}
|
||||
|
||||
function closeSslResultModal() {
|
||||
document.getElementById('sslResultModal').style.display = 'none';
|
||||
document.getElementById('sslResultOutput').style.display = 'none';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') { closeRequestModal(); closeSslResultModal(); }
|
||||
});
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
</script>
|
||||
<script src="/assets/js/server-ssl.js"></script>
|
||||
|
||||
0
views/servers/system_info.php
Executable file → Normal file
0
views/servers/system_info.php
Executable file → Normal file
@@ -121,46 +121,6 @@ $lastLogins = $sysusers['last_logins'] ?? '';
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
||||
|
||||
function showAddUser() { document.getElementById('addUserModal').style.display = 'flex'; }
|
||||
function showAddGroup() { document.getElementById('addGroupModal').style.display = 'flex'; }
|
||||
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
|
||||
|
||||
function doAddUser() {
|
||||
const data = 'action=add&username=' + encodeURIComponent(document.getElementById('suUser').value)
|
||||
+ '&password=' + encodeURIComponent(document.getElementById('suPass').value)
|
||||
+ '&groups=' + encodeURIComponent(document.getElementById('suGroups').value)
|
||||
+ '&create_home=' + (document.getElementById('suHome').checked ? '1' : '0')
|
||||
+ '&_csrf_token=' + encodeURIComponent(getCsrfToken());
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:data})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
closeModal('addUserModal');
|
||||
}
|
||||
|
||||
function doAddGroup() {
|
||||
const data = 'action=addgroup&group=' + encodeURIComponent(document.getElementById('sgGroup').value) + '&_csrf_token=' + encodeURIComponent(getCsrfToken());
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:data})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
closeModal('addGroupModal');
|
||||
}
|
||||
|
||||
function lockUser(u) {
|
||||
if(!confirm('Lock user \''+u+'\'?'))return;
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=lock&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
function unlockUser(u) {
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=unlock&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
function deleteUser(u) {
|
||||
if(!confirm('Delete user \''+u+'\'?\nThis cannot be undone.'))return;
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=delete&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeModal('addUserModal');closeModal('addGroupModal')}});
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
</script>
|
||||
<script src="/assets/js/system-users.js"></script>
|
||||
|
||||
@@ -71,66 +71,4 @@
|
||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const allCards = document.querySelectorAll('.team-card');
|
||||
|
||||
function filterTeams(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
const grid = document.getElementById('teamsGrid');
|
||||
const empty = document.getElementById('emptyState');
|
||||
const clearBtn = document.getElementById('searchClear');
|
||||
let visible = 0;
|
||||
|
||||
clearBtn.style.display = q ? 'block' : 'none';
|
||||
|
||||
if (grid) {
|
||||
allCards.forEach(function(card) {
|
||||
const name = card.dataset.name || '';
|
||||
const desc = card.dataset.desc || '';
|
||||
const match = !q || name.includes(q) || desc.includes(q);
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) visible++;
|
||||
});
|
||||
|
||||
if (visible === 0) {
|
||||
if (!empty) {
|
||||
const container = document.getElementById('teamsContainer');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'empty-state';
|
||||
div.id = 'emptyState';
|
||||
div.innerHTML = '<i class="fas fa-users-cog"></i><p>No teams match your search.</p>';
|
||||
container.appendChild(div);
|
||||
} else {
|
||||
empty.querySelector('p').textContent = 'No teams match your search.';
|
||||
empty.style.display = '';
|
||||
}
|
||||
} else {
|
||||
if (empty) empty.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update URL without reload
|
||||
const url = q ? '?search=' + encodeURIComponent(query) : window.location.pathname;
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
document.getElementById('teamSearch')?.addEventListener('input', function() {
|
||||
filterTeams(this.value);
|
||||
});
|
||||
|
||||
// Restore search on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('teamSearch');
|
||||
if (searchInput && searchInput.value) {
|
||||
filterTeams(searchInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
function deleteTeam(id, name) {
|
||||
if (confirm('Delete team "' + name + '"?\nThis will remove access for all members.')) {
|
||||
const form = document.getElementById('deleteForm');
|
||||
form.action = '/teams/' + id + '/delete';
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/team-index.js"></script>
|
||||
|
||||
@@ -146,200 +146,8 @@ $availableServers = $availableServers ?? [];
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
function editTeam() {
|
||||
const name = prompt('Team name:', '<?= htmlspecialchars(addslashes($team['name'] ?? '')) ?>');
|
||||
if (!name) return;
|
||||
const desc = prompt('Description:', '<?= htmlspecialchars(addslashes($team['description'] ?? '')) ?>');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('name', name);
|
||||
form.append('description', desc || '');
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/<?= $team['id'] ?>/update', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function deleteTeam() {
|
||||
if (!confirm('Delete this team? Members will lose access to assigned servers.')) return;
|
||||
document.getElementById('postForm').action = '/teams/<?= $team['id'] ?>/delete';
|
||||
document.getElementById('postForm').submit();
|
||||
}
|
||||
|
||||
/* ───── Autocomplete para usuarios ───── */
|
||||
let searchTimeout = null;
|
||||
let selectedUserId = null;
|
||||
|
||||
document.getElementById('addUserInput').addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
const q = this.value.trim();
|
||||
selectedUserId = null;
|
||||
|
||||
if (q.length < 2) {
|
||||
document.getElementById('userDropdown').classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(function() {
|
||||
fetch('/teams/search-users?q=' + encodeURIComponent(q), {
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const dropdown = document.getElementById('userDropdown');
|
||||
dropdown.innerHTML = '';
|
||||
if (data.users && data.users.length > 0) {
|
||||
data.users.forEach(function(u) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'autocomplete-item';
|
||||
item.innerHTML = '<strong>' + escapeHtml(u.username) + '</strong> <small>' + escapeHtml(u.email) + '</small>';
|
||||
item.dataset.id = u.id;
|
||||
item.dataset.username = u.username;
|
||||
item.addEventListener('click', function() {
|
||||
selectUser(u.id, u.username);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
dropdown.classList.add('active');
|
||||
} else {
|
||||
dropdown.innerHTML = '<div class="autocomplete-empty">No users found</div>';
|
||||
dropdown.classList.add('active');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('userDropdown').classList.remove('active');
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('.autocomplete')) {
|
||||
document.getElementById('userDropdown').classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
function selectUser(id, username) {
|
||||
selectedUserId = id;
|
||||
document.getElementById('addUserInput').value = username;
|
||||
document.getElementById('userDropdown').classList.remove('active');
|
||||
|
||||
// Auto-add the user
|
||||
const form = new FormData();
|
||||
form.append('user_id', id);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/<?= $team['id'] ?>/add-user', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
showToast('success', username + ' added to team');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast('error', res.message);
|
||||
document.getElementById('addUserInput').value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ───── Funciones existentes ───── */
|
||||
function removeMember(userId, username) {
|
||||
if (!confirm('Remove "' + username + '" from this team?')) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('user_id', userId);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/<?= $team['id'] ?>/remove-user', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function addServer(e) {
|
||||
e.preventDefault();
|
||||
const serverId = document.getElementById('addServerId').value;
|
||||
const role = document.getElementById('addServerRole').value;
|
||||
if (!serverId) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('server_id', serverId);
|
||||
form.append('role', role);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/<?= $team['id'] ?>/add-server', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function removeServer(serverId, name) {
|
||||
if (!confirm('Remove "' + name + '" from this team?')) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('server_id', serverId);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/<?= $team['id'] ?>/remove-server', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', res.message); location.reload(); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function updateServerRole(serverId, role) {
|
||||
const form = new FormData();
|
||||
form.append('server_id', serverId);
|
||||
form.append('role', role);
|
||||
form.append('_csrf_token', getCsrfToken());
|
||||
|
||||
fetch('/teams/<?= $team['id'] ?>/update-server-role', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
body: form,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) { showToast('success', 'Role updated to ' + role); }
|
||||
else { showToast('error', res.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
const teamId = <?= $team['id'] ?>;
|
||||
const teamName = '<?= htmlspecialchars(addslashes($team['name'] ?? '')) ?>';
|
||||
const teamDescription = '<?= htmlspecialchars(addslashes($team['description'] ?? '')) ?>';
|
||||
</script>
|
||||
<script src="/assets/js/team-show.js"></script>
|
||||
|
||||
@@ -149,121 +149,5 @@ $commandHistory = $commandHistory ?? [];
|
||||
|
||||
<script>
|
||||
const serverId = <?= $server['id'] ?>;
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
}
|
||||
|
||||
const terminalInput = document.getElementById('terminalInput');
|
||||
const terminalOutput = document.getElementById('terminalOutput');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
|
||||
terminalInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
executeCommand();
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function executeCommand() {
|
||||
const command = terminalInput.value.trim();
|
||||
if (!command) return;
|
||||
|
||||
appendTerminalLine(command, 'command');
|
||||
|
||||
terminalInput.value = '';
|
||||
terminalInput.disabled = true;
|
||||
document.getElementById('executeBtn').disabled = true;
|
||||
|
||||
updateStatus('executing', 'Executing...');
|
||||
|
||||
fetch('/terminal/' + serverId + '/execute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: 'command=' + encodeURIComponent(command) + '&_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
terminalInput.disabled = false;
|
||||
document.getElementById('executeBtn').disabled = false;
|
||||
terminalInput.focus();
|
||||
|
||||
if (data.success) {
|
||||
if (data.output) {
|
||||
appendTerminalLine(data.output, 'output');
|
||||
}
|
||||
if (data.stderr) {
|
||||
appendTerminalLine(data.stderr, 'error');
|
||||
}
|
||||
updateStatus('connected', 'Connected');
|
||||
} else {
|
||||
appendTerminalLine(data.message || 'Command failed', 'error');
|
||||
updateStatus('error', 'Error');
|
||||
}
|
||||
|
||||
const terminalBody = document.getElementById('terminalOutput');
|
||||
terminalBody.scrollTop = terminalBody.scrollHeight;
|
||||
})
|
||||
.catch(err => {
|
||||
terminalInput.disabled = false;
|
||||
document.getElementById('executeBtn').disabled = false;
|
||||
appendTerminalLine('Connection error: ' + err.message, 'error');
|
||||
updateStatus('error', 'Error');
|
||||
});
|
||||
}
|
||||
|
||||
function appendTerminalLine(text, type) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'terminal-line terminal-' + type;
|
||||
if (type === 'command') {
|
||||
line.innerHTML = '<span class="terminal-prompt-inline">$</span> ' + escapeHtml(text);
|
||||
} else {
|
||||
line.innerHTML = '<pre>' + escapeHtml(text) + '</pre>';
|
||||
}
|
||||
terminalOutput.appendChild(line);
|
||||
terminalOutput.scrollTop = terminalOutput.scrollHeight;
|
||||
}
|
||||
|
||||
function runQuickCmd(cmd) {
|
||||
terminalInput.value = cmd;
|
||||
executeCommand();
|
||||
}
|
||||
|
||||
function clearTerminal() {
|
||||
terminalOutput.innerHTML = '';
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
if (!confirm('Clear all command history for this server?')) return;
|
||||
|
||||
fetch('/terminal/' + serverId + '/clear-history', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': getCsrfToken(),
|
||||
},
|
||||
body: '_csrf_token=' + encodeURIComponent(getCsrfToken()),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showToast(data.success ? 'success' : 'error', data.message);
|
||||
if (data.success) location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function updateStatus(status, text) {
|
||||
const dot = connectionStatus.querySelector('.status-dot');
|
||||
dot.className = 'status-dot ' + status;
|
||||
connectionStatus.lastChild.textContent = ' ' + text;
|
||||
}
|
||||
|
||||
terminalInput.focus();
|
||||
</script>
|
||||
<script src="/assets/js/terminal.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user