refactor: extract all inline CSS/JS from PHP files into separate asset files #88

Merged
rafaga21 merged 1 commits from fix/policy-container-width into master 2026-06-14 08:05:52 -04:00
51 changed files with 3152 additions and 3269 deletions

View 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; }
}

View 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; }
}

View 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';
});
}

View 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);
});
}

View 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
View File

View 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();
}

View 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);

View 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
View 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';
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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();
}
});

View 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(){});
}

View 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);
}

View 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;
}

View 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();
});

View 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,"&#39;") + '\'>' + 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(); });

View 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;
});
}

View 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(); }
});

View 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')}});

View 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();
}
}

View 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); }
});
}

View 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();

View File

@@ -23,12 +23,9 @@ try {
$app->run(); $app->run();
} catch (\Throwable $e) { } catch (\Throwable $e) {
http_response_code(500); http_response_code(500);
if (($_ENV['APP_DEBUG'] ?? false) === 'true') { if (isset($app)) {
echo '<h1>Server Error</h1>'; $app->getView()->error(500);
echo '<pre>' . htmlspecialchars($e->getMessage()) . '</pre>';
echo '<pre>' . $e->getTraceAsString() . '</pre>';
} else {
echo '<h1>500 Internal Server Error</h1>';
echo '<p>An unexpected error occurred. Please check the logs.</p>';
} }
echo '<h1>500 Internal Server Error</h1>';
echo '<p>An unexpected error occurred.</p>';
} }

View File

@@ -29,7 +29,7 @@ class AuthMiddleware
if (!headers_sent()) { if (!headers_sent()) {
header("Location: {$url}"); header("Location: {$url}");
} else { } else {
echo '<script>window.location.href="' . $url . '";</script>'; echo '<meta http-equiv="refresh" content="0;url=' . htmlspecialchars($url) . '">';
} }
exit; exit;
} }

View File

@@ -75,210 +75,12 @@ class RateLimitMiddleware
http_response_code(429); http_response_code(429);
header("Retry-After: {$retryAfter}"); header("Retry-After: {$retryAfter}");
$retryMinutes = (int) ceil($retryAfter / 60); echo App::getInstance()->getView()->renderView('errors.429', [
$retryAfterMs = $retryAfter * 1000; 'retryAfter' => $retryAfter,
$requestUri = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', ENT_QUOTES, 'UTF-8'); 'retryMinutes' => (int) ceil($retryAfter / 60),
'retryAfterMs' => $retryAfter * 1000,
echo <<<HTML 'requestUri' => htmlspecialchars($_SERVER['REQUEST_URI'] ?? '/', 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>
HTML;
exit; exit;
} }

View File

@@ -187,45 +187,4 @@ function selected(string $value, string $compare): string {
</div> </div>
</div> </div>
<script> <script src="/assets/js/audit-log.js"></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>

View File

@@ -139,45 +139,4 @@ $data = $notifications['data'] ?? [];
</div> </div>
</div> </div>
<script> <script src="/assets/js/admin-notifications.js"></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>

View File

@@ -102,22 +102,4 @@
</div> </div>
</div> </div>
<script> <script src="/assets/js/admin-security.js"></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>

View File

@@ -121,81 +121,4 @@
</div> </div>
</div> </div>
<script> <script src="/assets/js/admin-users.js"></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>

View File

@@ -164,144 +164,6 @@ $aggregatedMetrics = $aggregatedMetrics ?? [];
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script> <script>const aggregatedData = <?= json_encode($aggregatedMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;</script>
const aggregatedData = <?= json_encode($aggregatedMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>; <script src="/assets/js/dashboard-chart.js"></script>
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>
</div> </div>

203
views/errors/429.php Normal file
View 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>

View File

@@ -1,216 +1,154 @@
<?php
$user = $user ?? null;
$notifications = $notifications ?? [];
$unreadCount = $unreadCount ?? 0;
$teamId = $teamId ?? null;
$title = $title ?? 'ServerManager';
$content = $content ?? '';
$currentRoute = $_SERVER['REQUEST_URI'] ?? '/';
?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?= $_SESSION['csrf_token'] ?? '' ?>"> <title><?= htmlspecialchars($title) ?> — ServerManager</title>
<meta name="api-token" content="<?php <meta name="api-token" content="<?= htmlspecialchars($user['api_token'] ?? '') ?>">
$uid = $_SESSION['user_id'] ?? 0; <meta name="csrf-token" content="<?= $this->csrfToken() ?>">
$token = ''; <link rel="stylesheet" href="/assets/css/app.css">
if ($uid) { <link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">
$u = \ServerManager\Core\Database::getInstance()->fetch( <link rel="preconnect" href="https://fonts.googleapis.com">
'SELECT api_token FROM users WHERE id = ? AND status = \'active\'', [(int) $uid] <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">
$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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<a href="/dashboard" class="sidebar-logo"> <a href="/dashboard" class="sidebar-brand">
<i class="fas fa-server"></i> <i class="fas fa-cubes"></i>
<span class="logo-text">ServerManager</span> <span>ServerManager</span>
</a> </a>
<button class="sidebar-toggle" id="sidebarToggle" title="Toggle sidebar">
<i class="fas fa-bars"></i>
</button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<ul class="nav-list"> <!-- Dashboard -->
<li class="nav-item"> <a href="/dashboard" class="nav-item <?= str_starts_with($currentRoute, '/dashboard') ? 'active' : '' ?>">
<a href="/dashboard" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/dashboard') ? 'active' : '' ?>"> <i class="fas fa-chart-pie"></i>
<i class="fas fa-tachometer-alt"></i> <span>Dashboard</span>
<span>Dashboard</span> </a>
</a>
</li> <!-- Servers -->
<li class="nav-item"> <div class="nav-item-accordion <?= str_starts_with($currentRoute, '/servers') || str_starts_with($currentRoute, '/terminal') ? 'expanded' : '' ?>">
<a href="/notifications" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/notifications') ? 'active' : '' ?>"> <a href="#" class="nav-item nav-item-toggle <?= str_starts_with($currentRoute, '/servers') ? 'active' : '' ?>" onclick="toggleServerList(event)">
<i class="fas fa-bell"></i> <i class="fas fa-server"></i>
<span>Notifications</span> <span>Servers</span>
</a> <i class="fas fa-chevron-right nav-chevron"></i>
</li> </a>
<li class="nav-item nav-item-accordion <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/servers') ? 'active' : '' ?>"> <div class="nav-sublist" id="serverList" style="<?= str_starts_with($currentRoute, '/servers') || str_starts_with($currentRoute, '/terminal') ? '' : '' ?>">
<a href="#" class="nav-link" onclick="toggleServerList(event)"> <a href="/servers" class="nav-sub-item"><i class="fas fa-list"></i> All Servers</a>
<i class="fas fa-hdd"></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>
</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' : '' ?>">
<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>
</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> </div>
</div>
<!-- Teams -->
<a href="/teams" class="nav-item <?= str_starts_with($currentRoute, '/teams') ? 'active' : '' ?>">
<i class="fas fa-users"></i>
<span>Teams</span>
</a>
<!-- 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>
</nav>
</aside> </aside>
<div class="mobile-overlay" id="mobileOverlay"></div> <!-- Main Content -->
<div class="main-area">
<main class="main-content"> <!-- Topbar -->
<header class="top-bar"> <header class="topbar">
<div class="top-bar-left"> <div class="topbar-left">
<button class="mobile-toggle" id="mobileToggle"> <button class="btn btn-icon" id="sidebarToggle" onclick="document.getElementById('sidebar').classList.toggle('collapsed')">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<div class="breadcrumb">
<?= htmlspecialchars($title ?? 'ServerManager') ?>
</div>
</div> </div>
<div class="top-bar-right"> <div class="topbar-right">
<div class="top-bar-actions"> <div class="notification-btn-wrapper">
<button class="btn-icon" id="refreshBtn" title="Refresh"> <button class="btn btn-icon notification-btn" onclick="toggleNotifications()" title="Notifications">
<i class="fas fa-sync-alt"></i> <i class="fas fa-bell"></i>
<span class="notification-badge" id="notificationBadge" style="display:<?= $unreadCount > 0 ? 'inline-flex' : 'none' ?>">
<?= $unreadCount > 99 ? '99+' : $unreadCount ?>
</span>
</button> </button>
<button class="btn-icon" id="themeToggle" title="Toggle theme"> <div class="notification-dropdown" id="notifDropdown">
<i class="fas fa-moon"></i> <div class="notif-header">
</button> <h3>Notifications</h3>
<div class="notification-btn-wrapper"> <button class="btn btn-text btn-sm" id="markAllReadBtn" onclick="markAllRead()"
<button class="btn-icon notification-btn" id="notificationBell" title="Notifications" onclick="toggleNotifications()"> style="display:<?= $unreadCount > 0 ? 'inline-flex' : 'none' ?>">
<i class="fas fa-bell"></i> <i class="fas fa-check-double"></i> Mark all read
<span class="notification-badge" id="notificationBadge" style="display:none">0</span> </button>
</button> </div>
<div class="notification-dropdown" id="notifDropdown"> <div class="notif-list" id="notifList">
<div class="notif-dropdown-header"> <div style="padding:40px 20px;text-align:center;color:var(--text-muted)">
<span style="font-weight:600;font-size:0.9em">Notifications</span> <i class="fas fa-spinner fa-spin" style="font-size:1.5em;margin-bottom:10px;display:block"></i>
<div style="display:flex;align-items:center;gap:6px"> <span>Loading notifications...</span>
<button class="btn-text" id="markAllReadBtn" onclick="markAllRead()" style="display:none;font-size:0.78em;padding:2px 6px;border-radius:4px">
<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">&times;</button>
</div>
</div>
<div class="notif-dropdown-body" 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>
</div> </div>
</div> </div>
<span class="topbar-user">
<i class="fas fa-user-circle"></i>
<?= htmlspecialchars($user['username'] ?? 'Guest') ?>
</span>
</div> </div>
</header> </header>
<div class="content-area"> <!-- Page Content -->
<?php if ($flash = \ServerManager\Core\Session::getAllFlashes()): ?> <main class="page-content">
<?php foreach ($flash as $type => $messages): ?> <div class="container">
<?php foreach ($messages as $message): ?> <?php if (isset($_SESSION['_flash'])): ?>
<div class="toast toast-<?= htmlspecialchars($type) ?>" data-autohide="true"> <?php foreach ($_SESSION['_flash'] as $type => $messages): ?>
<div class="toast-body"> <?php foreach ($messages as $msg): ?>
<i class="fas fa-<?= $type === 'success' ? 'check-circle' : ($type === 'error' ? 'times-circle' : 'info-circle') ?>"></i> <div class="alert alert-<?= $type === 'error' ? 'danger' : $type ?>">
<?= htmlspecialchars($message) ?> <i class="fas fa-<?= $type === 'success' ? 'check-circle' : ($type === 'error' ? 'exclamation-circle' : 'info-circle') ?>"></i>
<?= htmlspecialchars($msg) ?>
</div> </div>
<button class="toast-close">&times;</button> <?php endforeach; ?>
</div>
<?php endforeach; ?> <?php endforeach; ?>
<?php endforeach; ?> <?php unset($_SESSION['_flash']); ?>
<?php endif; ?> <?php endif; ?>
<?= $content ?? '' ?> <?= $content ?? '' ?>
</div> </div>
</main> </main>
</div>
</div> </div>
<div id="toastContainer" class="toast-container"></div> <div id="toastContainer" class="toast-container"></div>
@@ -221,211 +159,6 @@
<script src="<?= htmlspecialchars($script) ?>"></script> <script src="<?= htmlspecialchars($script) ?>"></script>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
<script> <script src="/assets/js/main.js"></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>
</body> </body>
</html> </html>

View File

@@ -67,25 +67,4 @@ $data = $notifications ?? [];
</div> </div>
<?php endif; ?> <?php endif; ?>
<script> <script src="/assets/js/notifications.js"></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>

View File

@@ -1,462 +1,4 @@
<style> <?php ?><link rel="stylesheet" href="/assets/css/landing.css">
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>
<div class="landing-page"> <div class="landing-page">
<section class="landing-hero"> <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-terminal"></i></div>
<div class="floating-elem"><i class="fas fa-database"></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-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-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>
<div class="hero-icon-wrapper"> <div class="hero-icon-wrapper">
@@ -478,59 +20,37 @@ body.auth-page .auth-container {
</div> </div>
<h1 class="hero-title"> <h1 class="hero-title">
Manage Your Servers<br> Manage your <span class="gradient-text">servers</span> with ease
<span class="gradient-text">From One Place</span>
</h1> </h1>
<p class="hero-subtitle"> <p class="hero-subtitle">
ServerManager is a powerful, web-based platform for managing Linux servers. A self-hosted, open-source platform for remote server management.
Monitor, control, and deploy across your entire infrastructure from a single dashboard. Monitor, maintain, and control your infrastructure from a clean web dashboard.
</p> </p>
<div class="hero-cta"> <div class="hero-cta">
<a href="/login" class="btn btn-primary-custom"> <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>
<a href="/register" class="btn btn-secondary-custom"> <a href="https://github.com/devlab-lat/server-manager" class="btn btn-secondary-custom" target="_blank">
<i class="fas fa-user-plus"></i> Create Account <i class="fab fa-github"></i> View on GitHub
</a> </a>
</div> </div>
<!-- Terminal demo -->
<div class="hero-terminal"> <div class="hero-terminal">
<div class="terminal-bar"> <div class="terminal-bar">
<div class="terminal-dot red"></div> <span class="terminal-dot red"></span>
<div class="terminal-dot yellow"></div> <span class="terminal-dot yellow"></span>
<div class="terminal-dot green"></div> <span class="terminal-dot green"></span>
<span class="terminal-title">servermanager ssh-session</span> <span class="terminal-title">server-manager@dashboard:~</span>
</div> </div>
<div class="terminal-body-landing"> <div class="terminal-body-landing">
<div class="terminal-line-landing" style="animation-delay: 0.9s"> <div class="terminal-line-landing" style="animation-delay:0.1s"><span class="prompt">$</span> <span class="cmd">serverman list --servers</span></div>
<span class="prompt">admin@servermanager:~$</span> <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>
<span class="cmd"> systemctl status --all</span> <div class="terminal-line-landing" style="animation-delay:0.9s"><span class="prompt">$</span> <span class="cmd">serverman status web-01</span></div>
</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.1s"> <div class="terminal-line-landing" style="animation-delay:1.7s"><span class="prompt">$</span> <span class="cmd"><span class="blinking-cursor"></span></span></div>
<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> </div>
</div> </div>
@@ -540,76 +60,127 @@ body.auth-page .auth-container {
</div> </div>
</section> </section>
<!-- Features section -->
<section class="features-section" id="features"> <section class="features-section" id="features">
<div class="features-header"> <div class="features-header">
<h2>Everything You Need</h2> <h2>Everything you need to manage servers</h2>
<p>Complete server management toolkit at your fingertips</p> <p>A comprehensive toolkit for sysadmins and developers.</p>
</div> </div>
<div class="features-grid"> <div class="features-grid">
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon blue"><i class="fas fa-terminal"></i></div> <div class="feature-icon blue"><i class="fas fa-terminal"></i></div>
<h3>SSH Terminal</h3> <h3>Remote SSH Terminal</h3>
<p>Browser-based SSH terminal with real-time output, command history, and multi-session support.</p> <p>Browser-based SSH client with session persistence, tab support, and full terminal emulation.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon green"><i class="fas fa-chart-line"></i></div> <div class="feature-icon green"><i class="fas fa-chart-line"></i></div>
<h3>Real-Time Monitoring</h3> <h3>Real-time Monitoring</h3>
<p>Monitor CPU, memory, disk usage, and network traffic with live metrics and historical charts.</p> <p>Track CPU, memory, disk usage, and network activity with live-updating graphs and alerts.</p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<div class="feature-icon purple"><i class="fas fa-shield-alt"></i></div> <div class="feature-icon purple"><i class="fas fa-users-cog"></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>
<h3>Team Collaboration</h3> <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>
</div> </div>
</section> </section>
<section class="stats-section" id="stats"> <!-- Stats Section -->
<section class="stats-section">
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-item"> <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 class="stat-label">Servers Managed</div>
</div> </div>
<div class="stat-item"> <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 class="stat-label">Active Users</div>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<div class="stat-number" data-target="<?= (int) ($stats['online_servers'] ?? 0) ?>"><?= (int) ($stats['online_servers'] ?? 0) ?></div> <div class="stat-number" data-target="24">0</div>
<div class="stat-label">Servers Online</div> <div class="stat-label">Hour Monitoring</div>
</div> </div>
</div> </div>
</section> </section>
<!-- Footer -->
<footer class="landing-footer"> <footer class="landing-footer">
<div class="footer-links"> <div class="footer-links">
<a href="/privacy">Privacy Policy</a> <a href="/terms">Terms</a>
<a href="/terms">Terms & Conditions</a> <a href="/privacy">Privacy</a>
<a href="https://github.com/devlab-lat/server-manager">GitHub</a>
</div> </div>
<p>&copy; <?= date('Y') ?> ServerManager. All rights reserved.</p> <span>&copy; <?= date('Y') ?> ServerManager. Released under the MIT License.</span>
</footer> </footer>
</div> </div>
<!-- Page-specific JS -->
<script> <script>
(function() { document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.stat-number[data-target]').forEach(function(el) { // Animated counter
var target = parseInt(el.getAttribute('data-target'), 10); const counters = document.querySelectorAll('.stat-number');
el.textContent = target;
}); 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> </script>

View File

@@ -1,24 +1,4 @@
<style> <?php ?><link rel="stylesheet" href="/assets/css/legal.css">
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>
<div class="legal-page"> <div class="legal-page">
<div class="legal-container"> <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)"> <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 <?php
$lines = explode("\n", $content ?? ''); $lines = explode("\n", $content ?? '');
foreach ($lines as $line) { foreach ($lines as $line) {
$line = htmlspecialchars($line); $trimmed = trim($line);
if (preg_match('/^[A-Z][A-Z\s]+$/', trim($line))) { if ($trimmed === '') {
echo '<h2 style="color:var(--accent-primary);font-size:1.2rem;margin:2rem 0 1rem;font-weight:600">' . $line . '</h2>'; echo '<br>';
} elseif (preg_match('/^[a-z]\)\s/', trim($line))) { } elseif (str_starts_with($trimmed, '# ')) {
echo '<p style="margin:0.4rem 0 0.4rem 2rem">' . $line . '</p>'; 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 (preg_match('/^\d+\./', trim($line))) { } elseif (str_starts_with($trimmed, '## ')) {
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1.2rem 0 0.5rem;font-weight:600">' . $line . '</h3>'; 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>';
} elseif (trim($line) === '') {
echo '<div style="height:0.5rem"></div>';
} else { } else {
echo '<p style="margin:0.4rem 0">' . $line . '</p>'; echo '<p style="margin-bottom:0.75rem">' . htmlspecialchars($trimmed) . '</p>';
} }
} }
?> ?>
</div> </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">&larr; Back to ServerManager</a></p>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,24 +1,4 @@
<style> <?php ?><link rel="stylesheet" href="/assets/css/legal.css">
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>
<div class="legal-page"> <div class="legal-page">
<div class="legal-container"> <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)"> <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 <?php
$lines = explode("\n", $content ?? ''); $lines = explode("\n", $content ?? '');
foreach ($lines as $line) { foreach ($lines as $line) {
$line = htmlspecialchars($line); $trimmed = trim($line);
if (preg_match('/^[A-Z][A-Z\s]+$/', trim($line))) { if ($trimmed === '') {
echo '<h2 style="color:var(--accent-primary);font-size:1.2rem;margin:2rem 0 1rem;font-weight:600">' . $line . '</h2>'; echo '<br>';
} elseif (preg_match('/^\d+\.\d/', trim($line))) { } elseif (str_starts_with($trimmed, '# ')) {
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1rem 0 0.4rem 1.5rem;font-weight:600">' . $line . '</h3>'; 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 (preg_match('/^\d+\./', trim($line))) { } elseif (str_starts_with($trimmed, '## ')) {
echo '<h3 style="color:var(--text-primary);font-size:1rem;margin:1.2rem 0 0.5rem;font-weight:600">' . $line . '</h3>'; 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>';
} 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>';
} else { } else {
echo '<p style="margin:0.4rem 0">' . $line . '</p>'; echo '<p style="margin-bottom:0.75rem">' . htmlspecialchars($trimmed) . '</p>';
} }
} }
?> ?>
</div> </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">&larr; Back to ServerManager</a></p>
</div>
</div> </div>
</div> </div>
</div> </div>

42
views/servers/create.php Executable file → Normal file
View File

@@ -170,45 +170,5 @@ document.querySelectorAll('.auth-method-tabs .tab').forEach(tab => {
}); });
let permIndex = <?= !empty($savedPerms) && is_array($savedPerms) ? count($savedPerms) : 0 ?>; 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>
<script src="/assets/js/server-permissions.js"></script>

View File

@@ -92,322 +92,8 @@ $status = $db['status'] ?? 'inactive';
</div> </div>
<script> <script>
let currentDb = ''; const serverId = <?= json_encode($server['id']) ?>;
let currentTable = ''; const databases = <?= json_encode(array_column($databases, 'name')) ?>;
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;}
</script> </script>
<script src="/assets/js/database-manager.js"></script>
<?php endif; ?> <?php endif; ?>

42
views/servers/edit.php Executable file → Normal file
View File

@@ -155,45 +155,5 @@ $hasKey = !empty($server['ssh_key']);
<script> <script>
let permIndex = <?= count($permissions) ?>; 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>
<script src="/assets/js/server-permissions.js"></script>

33
views/servers/index.php Executable file → Normal file
View File

@@ -141,35 +141,4 @@ $groups = $groups ?? [];
</div> </div>
</div> </div>
<script> <script src="/assets/js/server-list.js"></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>

0
views/servers/logs.php Executable file → Normal file
View File

View File

@@ -321,326 +321,7 @@ $configValid = $nginx['config_valid'] ?? false;
</div> </div>
<script> <script>
let currentEditFile = null; const serverId = <?= json_encode($server['id']) ?>;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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();
}
});
</script> </script>
<script src="/assets/js/nginx-manager.js"></script>
<?php endif; ?> <?php endif; ?>

64
views/servers/processes.php Executable file → Normal file
View File

@@ -101,66 +101,6 @@ $processList = $processList ?? [];
</div> </div>
<script> <script>
let pendingPid = null; const serverId = <?= $server['id'] ?>;
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();
});
</script> </script>
<script src="/assets/js/server-processes.js"></script>

View File

@@ -49,92 +49,7 @@ $totalPages = max(1, ceil(count($services) / $perPage));
<script> <script>
const allServices = <?= json_encode($services, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>; const allServices = <?= json_encode($services, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>;
const perPage = <?= $perPage ?>; const perPage = <?= $perPage ?>;
const serverId = <?= $server['id'] ?>;
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,"&#39;") + '\'>' + 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(); });
</script> </script>
<script src="/assets/js/server-services.js"></script>

329
views/servers/show.php Executable file → Normal file
View File

@@ -370,330 +370,9 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
</form> </form>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script> <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> <script>
const historicalData = <?= json_encode($historicalMetrics, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES) ?>; const serverId = <?= $server['id'] ?>;
const serverName = '<?= addslashes($server['name'] ?? '') ?>';
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;
});
}
</script> </script>
<script src="/assets/js/server-show.js"></script>

View File

@@ -189,77 +189,6 @@ $certs = $ssl['certificates'] ?? [];
</div> </div>
<script> <script>
function getCsrfToken() { const serverId = <?= $server['id'] ?>;
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(); }
});
</script> </script>
<script src="/assets/js/server-ssl.js"></script>

0
views/servers/system_info.php Executable file → Normal file
View File

View File

@@ -121,46 +121,6 @@ $lastLogins = $sysusers['last_logins'] ?? '';
</div> </div>
<script> <script>
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; } const serverId = <?= $server['id'] ?>;
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')}});
</script> </script>
<script src="/assets/js/system-users.js"></script>

View File

@@ -71,66 +71,4 @@
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>"> <input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
</form> </form>
<script> <script src="/assets/js/team-index.js"></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>

View File

@@ -146,200 +146,8 @@ $availableServers = $availableServers ?? [];
</form> </form>
<script> <script>
function getCsrfToken() { const teamId = <?= $team['id'] ?>;
return document.querySelector('meta[name="csrf-token"]')?.content || ''; const teamName = '<?= htmlspecialchars(addslashes($team['name'] ?? '')) ?>';
} const teamDescription = '<?= htmlspecialchars(addslashes($team['description'] ?? '')) ?>';
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;
}
</script> </script>
<script src="/assets/js/team-show.js"></script>

View File

@@ -41,7 +41,7 @@ $commandHistory = $commandHistory ?? [];
\___ \ / _ \ '__\ \ / / _ \ '__| | |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '__| \___ \ / _ \ '__\ \ / / _ \ '__| | |\/| |/ _` | '_ \ / _` |/ _` |/ _ \ '__|
___) | __/ | \ V / __/ | | | | | (_| | | | | (_| | (_| | __/ | ___) | __/ | \ V / __/ | | | | | (_| | | | | (_| | (_| | __/ |
|____/ \___|_| \_/ \___|_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_| |____/ \___|_| \_/ \___|_| |_| |_|\__,_|_| |_|\__,_|\__, |\___|_|
|___/ |___/
</pre> </pre>
<p>Connected to <strong><?= htmlspecialchars($server['name']) ?></strong></p> <p>Connected to <strong><?= htmlspecialchars($server['name']) ?></strong></p>
<p class="text-muted">Type commands below to execute them on the remote server.</p> <p class="text-muted">Type commands below to execute them on the remote server.</p>
@@ -149,121 +149,5 @@ $commandHistory = $commandHistory ?? [];
<script> <script>
const serverId = <?= $server['id'] ?>; 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>
<script src="/assets/js/terminal.js"></script>