refactor: extract inline CSS/JS from views into separate asset files (20+ files)

Phase A — HTML removed from PHP files:
- RateLimitMiddleware.php: heredoc moved to views/errors/429.php
- public/index.php: inline HTML replaced with View::error()
- AuthMiddleware.php: <script> redirect replaced with <meta refresh>

Phase B — Inline CSS/JS extracted from views:
- CSS: landing.css (459 lines), legal.css (shared by terms+privacy)
- JS: 17 new files extracted from ~20 view files
  - main.js (layout sidebar + notifications)
  - dashboard-chart.js, server-show.js, server-services.js
  - nginx-manager.js, database-manager.js
  - terminal.js, team-show.js, team-index.js
  - server-list.js, server-permissions.js (shared by edit+create)
  - server-ssl.js, server-processes.js, system-users.js
  - admin-users.js, audit-log.js, admin-notifications.js, admin-security.js
  - notifications.js

Total: -3264 lines from views, +288 lines in new assets
This commit is contained in:
2026-06-14 08:04:01 -04:00
parent 142fe304c9
commit 88f3c1f964
51 changed files with 3152 additions and 3269 deletions

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